diff --git a/build.gradle b/build.gradle index 25c59e9e..f0fcfa63 100644 --- a/build.gradle +++ b/build.gradle @@ -1,89 +1,89 @@ buildscript { repositories { jcenter() google() } dependencies { classpath 'com.android.tools.build:gradle:3.1.2' } } apply plugin: 'com.android.application' android { buildToolsVersion '27.0.3' - compileSdkVersion 25 + compileSdkVersion 27 defaultConfig { - minSdkVersion 9 + minSdkVersion 16 targetSdkVersion 25 //multiDexEnabled true //testInstrumentationRunner "com.android.test.runner.MultiDexTestRunner" } dexOptions { javaMaxHeapSize "2g" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } sourceSets { main { manifest.srcFile 'AndroidManifest.xml' java.srcDirs = ['src'] resources.srcDirs = ['resources'] res.srcDirs = ['res'] assets.srcDirs = ['assets'] } androidTest { java.srcDirs = ['tests'] } } packagingOptions { pickFirst "META-INF/DEPENDENCIES" pickFirst "META-INF/LICENSE" pickFirst "META-INF/NOTICE" pickFirst "META-INF/BCKEY.SF" pickFirst "META-INF/BCKEY.DSA" pickFirst "META-INF/INDEX.LIST" pickFirst "META-INF/io.netty.versions.properties" } lintOptions { abortOnError false checkReleaseBuilds false } buildTypes { debug { minifyEnabled false useProguard false } release { //keep on 'release', set to 'all' when testing to make sure proguard is not deleting important stuff minifyEnabled true useProguard true proguardFiles getDefaultProguardFile('proguard-android.txt'),'proguard-rules.pro' } } } dependencies { repositories { jcenter() google() } - implementation 'com.android.support:support-v4:25.4.0' - implementation 'com.android.support:appcompat-v7:25.4.0' - implementation 'com.android.support:design:25.4.0' + implementation 'com.android.support:support-v4:27.1.1' + implementation 'com.android.support:appcompat-v7:27.1.1' + implementation 'com.android.support:design:27.1.1' implementation 'com.jakewharton:disklrucache:2.0.2' //For caching album art bitmaps implementation 'org.apache.sshd:sshd-core:0.8.0' //0.9 seems to fail on Android 6 and 1.+ requires java.nio.file, which doesn't exist in Android implementation 'com.madgag.spongycastle:pkix:1.54.0.0' //For SSL certificate generation // Testing androidTestImplementation 'org.mockito:mockito-core:1.10.19' androidTestImplementation 'com.google.dexmaker:dexmaker-mockito:1.1'// Because mockito has some problems with dex environment androidTestImplementation 'org.skyscreamer:jsonassert:1.3.0' testImplementation 'junit:junit:4.12' } diff --git a/src/org/kde/kdeconnect/Device.java b/src/org/kde/kdeconnect/Device.java index d5ffc778..76d42777 100644 --- a/src/org/kde/kdeconnect/Device.java +++ b/src/org/kde/kdeconnect/Device.java @@ -1,919 +1,920 @@ /* * Copyright 2014 Albert Vaca Cintora * * 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; 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.content.res.Resources; import android.graphics.drawable.Drawable; import android.os.Build; import android.preference.PreferenceManager; import android.support.v4.app.NotificationCompat; import android.support.v4.content.ContextCompat; import android.util.Base64; import android.util.Log; import org.kde.kdeconnect.Backends.BaseLink; import org.kde.kdeconnect.Backends.BasePairingHandler; import org.kde.kdeconnect.Backends.LanBackend.LanLinkProvider; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect_tp.R; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.PublicKey; import java.security.cert.Certificate; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.Vector; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; public class Device implements BaseLink.PacketReceiver { private final Context context; private final String deviceId; private String name; public PublicKey publicKey; public Certificate certificate; private int notificationId; private int protocolVersion; private DeviceType deviceType; private PairStatus pairStatus; private final CopyOnWriteArrayList pairingCallback = new CopyOnWriteArrayList<>(); private Map pairingHandlers = new HashMap<>(); private final CopyOnWriteArrayList links = new CopyOnWriteArrayList<>(); private List m_supportedPlugins = new ArrayList<>(); private final ConcurrentHashMap plugins = new ConcurrentHashMap<>(); private final ConcurrentHashMap failedPlugins = new ConcurrentHashMap<>(); private final ConcurrentHashMap pluginsWithoutPermissions = new ConcurrentHashMap<>(); private final ConcurrentHashMap pluginsWithoutOptionalPermissions = new ConcurrentHashMap<>(); private Map> pluginsByIncomingInterface = new HashMap<>(); private final SharedPreferences settings; private final CopyOnWriteArrayList pluginsChangedListeners = new CopyOnWriteArrayList<>(); public interface PluginsChangedListener { void onPluginsChanged(Device device); } public enum PairStatus { NotPaired, Paired } public enum DeviceType { Phone, Tablet, Computer, Tv; public static DeviceType FromString(String s) { if ("tablet".equals(s)) return Tablet; if ("phone".equals(s)) return Phone; if ("tv".equals(s)) return Tv; return Computer; //Default } public String toString() { switch (this) { case Tablet: return "tablet"; case Phone: return "phone"; case Tv: return "tv"; default: return "desktop"; } } } public interface PairingCallback { void incomingRequest(); void pairingSuccessful(); void pairingFailed(String error); void unpaired(); } //Remembered trusted device, we need to wait for a incoming devicelink to communicate Device(Context context, String deviceId) { settings = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE); //Log.e("Device","Constructor A"); this.context = context; this.deviceId = deviceId; this.name = settings.getString("deviceName", context.getString(R.string.unknown_device)); this.pairStatus = PairStatus.Paired; this.protocolVersion = NetworkPacket.ProtocolVersion; //We don't know it yet this.deviceType = DeviceType.FromString(settings.getString("deviceType", "desktop")); try { String publicKeyStr = settings.getString("publicKey", null); if (publicKeyStr != null) { byte[] publicKeyBytes = Base64.decode(publicKeyStr, 0); publicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(publicKeyBytes)); } } catch (Exception e) { e.printStackTrace(); Log.e("KDE/Device", "Exception deserializing stored public key for device"); } //Assume every plugin is supported until addLink is called and we can get the actual list m_supportedPlugins = new Vector<>(PluginFactory.getAvailablePlugins()); //Do not load plugins yet, the device is not present //reloadPluginsFromSettings(); } //Device known via an incoming connection sent to us via a devicelink, we know everything but we don't trust it yet Device(Context context, NetworkPacket np, BaseLink dl) { //Log.e("Device","Constructor B"); this.context = context; this.deviceId = np.getString("deviceId"); this.name = context.getString(R.string.unknown_device); //We read it in addLink this.pairStatus = PairStatus.NotPaired; this.protocolVersion = 0; this.deviceType = DeviceType.Computer; this.publicKey = null; settings = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE); addLink(np, dl); } public String getName() { return name != null ? name : context.getString(R.string.unknown_device); } public Drawable getIcon() { int drawableId; switch (deviceType) { case Phone: drawableId = R.drawable.ic_device_phone; break; case Tablet: drawableId = R.drawable.ic_device_tablet; break; case Tv: drawableId = R.drawable.ic_device_tv; break; default: drawableId = R.drawable.ic_device_laptop; } return ContextCompat.getDrawable(context, drawableId); } public DeviceType getDeviceType() { return deviceType; } public String getDeviceId() { return deviceId; } public Context getContext() { return context; } //Returns 0 if the version matches, < 0 if it is older or > 0 if it is newer public int compareProtocolVersion() { return protocolVersion - NetworkPacket.ProtocolVersion; } // // Pairing-related functions // public boolean isPaired() { return pairStatus == PairStatus.Paired; } /* Asks all pairing handlers that, is pair requested? */ public boolean isPairRequested() { boolean pairRequested = false; for (BasePairingHandler ph : pairingHandlers.values()) { pairRequested = pairRequested || ph.isPairRequested(); } return pairRequested; } /* Asks all pairing handlers that, is pair requested by peer? */ public boolean isPairRequestedByPeer() { boolean pairRequestedByPeer = false; for (BasePairingHandler ph : pairingHandlers.values()) { pairRequestedByPeer = pairRequestedByPeer || ph.isPairRequestedByPeer(); } return pairRequestedByPeer; } public void addPairingCallback(PairingCallback callback) { pairingCallback.add(callback); } public void removePairingCallback(PairingCallback callback) { pairingCallback.remove(callback); } public void requestPairing() { Resources res = context.getResources(); if (isPaired()) { for (PairingCallback cb : pairingCallback) { cb.pairingFailed(res.getString(R.string.error_already_paired)); } return; } if (!isReachable()) { for (PairingCallback cb : pairingCallback) { cb.pairingFailed(res.getString(R.string.error_not_reachable)); } return; } for (BasePairingHandler ph : pairingHandlers.values()) { ph.requestPairing(); } } public void unpair() { for (BasePairingHandler ph : pairingHandlers.values()) { ph.unpair(); } unpairInternal(); // Even if there are no pairing handlers, unpair } /** * This method does not send an unpair package, instead it unpairs internally by deleting trusted device info. . Likely to be called after sending package from * pairing handler */ private void unpairInternal() { //Log.e("Device","Unpairing (unpairInternal)"); pairStatus = PairStatus.NotPaired; SharedPreferences preferences = context.getSharedPreferences("trusted_devices", Context.MODE_PRIVATE); preferences.edit().remove(deviceId).apply(); SharedPreferences devicePreferences = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE); devicePreferences.edit().clear().apply(); for (PairingCallback cb : pairingCallback) cb.unpaired(); reloadPluginsFromSettings(); } /* This method should be called after pairing is done from pairing handler. Calling this method again should not create any problem as most of the things will get over writter*/ private void pairingDone() { //Log.e("Device", "Storing as trusted, deviceId: "+deviceId); hidePairingNotification(); pairStatus = PairStatus.Paired; //Store as trusted device SharedPreferences preferences = context.getSharedPreferences("trusted_devices", Context.MODE_PRIVATE); preferences.edit().putBoolean(deviceId, true).apply(); SharedPreferences.Editor editor = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE).edit(); editor.putString("deviceName", name); editor.putString("deviceType", deviceType.toString()); editor.apply(); reloadPluginsFromSettings(); for (PairingCallback cb : pairingCallback) { cb.pairingSuccessful(); } } /* This method is called after accepting pair request form GUI */ public void acceptPairing() { Log.i("KDE/Device", "Accepted pair request started by the other device"); for (BasePairingHandler ph : pairingHandlers.values()) { ph.acceptPairing(); } } /* This method is called after rejecting pairing from GUI */ public void rejectPairing() { Log.i("KDE/Device", "Rejected pair request started by the other device"); //Log.e("Device","Unpairing (rejectPairing)"); pairStatus = PairStatus.NotPaired; for (BasePairingHandler ph : pairingHandlers.values()) { ph.rejectPairing(); } for (PairingCallback cb : pairingCallback) { cb.pairingFailed(context.getString(R.string.error_canceled_by_user)); } } // // Notification related methods used during pairing // public int getNotificationId() { return notificationId; } public void displayPairingNotification() { hidePairingNotification(); notificationId = (int) System.currentTimeMillis(); Intent intent = new Intent(getContext(), MainActivity.class); intent.putExtra("deviceId", getDeviceId()); intent.putExtra("notificationId", notificationId); PendingIntent pendingIntent = PendingIntent.getActivity(getContext(), 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); Intent acceptIntent = new Intent(getContext(), MainActivity.class); Intent rejectIntent = new Intent(getContext(), MainActivity.class); acceptIntent.putExtra("deviceId", getDeviceId()); acceptIntent.putExtra("notificationId", notificationId); acceptIntent.setAction("action " + System.currentTimeMillis()); acceptIntent.putExtra(MainActivity.PAIR_REQUEST_STATUS, MainActivity.PAIRING_ACCEPTED); rejectIntent.putExtra("deviceId", getDeviceId()); rejectIntent.putExtra("notificationId", notificationId); rejectIntent.setAction("action " + System.currentTimeMillis()); rejectIntent.putExtra(MainActivity.PAIR_REQUEST_STATUS, MainActivity.PAIRING_REJECTED); PendingIntent acceptedPendingIntent = PendingIntent.getActivity(getContext(), 2, acceptIntent, PendingIntent.FLAG_ONE_SHOT); PendingIntent rejectedPendingIntent = PendingIntent.getActivity(getContext(), 4, rejectIntent, PendingIntent.FLAG_ONE_SHOT); Resources res = getContext().getResources(); - Notification noti = new NotificationCompat.Builder(getContext()) + final NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + + Notification noti = new NotificationCompat.Builder(getContext(), NotificationHelper.getDefaultChannelId(notificationManager)) .setContentTitle(res.getString(R.string.pairing_request_from, getName())) .setContentText(res.getString(R.string.tap_to_answer)) .setContentIntent(pendingIntent) .setTicker(res.getString(R.string.pair_requested)) .setSmallIcon(R.drawable.ic_notification) .addAction(R.drawable.ic_accept_pairing, res.getString(R.string.pairing_accept), acceptedPendingIntent) .addAction(R.drawable.ic_reject_pairing, res.getString(R.string.pairing_reject), rejectedPendingIntent) .setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL) .build(); - final NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); NotificationHelper.notifyCompat(notificationManager, notificationId, noti); BackgroundService.addGuiInUseCounter(context); } public void hidePairingNotification() { final NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.cancel(notificationId); BackgroundService.removeGuiInUseCounter(context); } // // ComputerLink-related functions // public boolean isReachable() { return !links.isEmpty(); } public void addLink(NetworkPacket identityPacket, BaseLink link) { //FilesHelper.LogOpenFileCount(); this.protocolVersion = identityPacket.getInt("protocolVersion"); if (identityPacket.has("deviceName")) { this.name = identityPacket.getString("deviceName", this.name); SharedPreferences.Editor editor = settings.edit(); editor.putString("deviceName", this.name); editor.apply(); } if (identityPacket.has("deviceType")) { this.deviceType = DeviceType.FromString(identityPacket.getString("deviceType", "desktop")); } if (identityPacket.has("certificate")) { String certificateString = identityPacket.getString("certificate"); try { byte[] certificateBytes = Base64.decode(certificateString, 0); certificate = SslHelper.parseCertificate(certificateBytes); Log.i("KDE/Device", "Got certificate "); } catch (Exception e) { e.printStackTrace(); Log.e("KDE/Device", "Error getting certificate"); } } links.add(link); try { SharedPreferences globalSettings = PreferenceManager.getDefaultSharedPreferences(context); byte[] privateKeyBytes = Base64.decode(globalSettings.getString("privateKey", ""), 0); PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)); link.setPrivateKey(privateKey); } catch (Exception e) { e.printStackTrace(); Log.e("KDE/Device", "Exception reading our own private key"); //Should not happen } Log.i("KDE/Device", "addLink " + link.getLinkProvider().getName() + " -> " + getName() + " active links: " + links.size()); if (!pairingHandlers.containsKey(link.getName())) { BasePairingHandler.PairingHandlerCallback callback = new BasePairingHandler.PairingHandlerCallback() { @Override public void incomingRequest() { for (PairingCallback cb : pairingCallback) { cb.incomingRequest(); } } @Override public void pairingDone() { Device.this.pairingDone(); } @Override public void pairingFailed(String error) { for (PairingCallback cb : pairingCallback) { cb.pairingFailed(error); } } @Override public void unpaired() { unpairInternal(); } }; pairingHandlers.put(link.getName(), link.getPairingHandler(this, callback)); } Set outgoingCapabilities = identityPacket.getStringSet("outgoingCapabilities", null); Set incomingCapabilities = identityPacket.getStringSet("incomingCapabilities", null); if (incomingCapabilities != null && outgoingCapabilities != null) { m_supportedPlugins = new Vector<>(PluginFactory.pluginsForCapabilities(context, incomingCapabilities, outgoingCapabilities)); } else { m_supportedPlugins = new Vector<>(PluginFactory.getAvailablePlugins()); } link.addPacketReceiver(this); reloadPluginsFromSettings(); } public void removeLink(BaseLink link) { //FilesHelper.LogOpenFileCount(); /* Remove pairing handler corresponding to that link too if it was the only link*/ boolean linkPresent = false; for (BaseLink bl : links) { if (bl.getName().equals(link.getName())) { linkPresent = true; break; } } if (!linkPresent) { pairingHandlers.remove(link.getName()); } link.removePacketReceiver(this); links.remove(link); Log.i("KDE/Device", "removeLink: " + link.getLinkProvider().getName() + " -> " + getName() + " active links: " + links.size()); if (links.isEmpty()) { reloadPluginsFromSettings(); } } @Override public void onPacketReceived(NetworkPacket np) { hackToMakeRetrocompatiblePacketTypes(np); if (NetworkPacket.PACKET_TYPE_PAIR.equals(np.getType())) { Log.i("KDE/Device", "Pair package"); for (BasePairingHandler ph : pairingHandlers.values()) { try { ph.packageReceived(np); } catch (Exception e) { e.printStackTrace(); Log.e("PairingPacketReceived", "Exception"); } } } else if (isPaired()) { //If capabilities are not supported, iterate all plugins Collection targetPlugins = pluginsByIncomingInterface.get(np.getType()); if (targetPlugins != null && !targetPlugins.isEmpty()) { for (String pluginKey : targetPlugins) { Plugin plugin = plugins.get(pluginKey); try { plugin.onPacketReceived(np); } catch (Exception e) { e.printStackTrace(); Log.e("KDE/Device", "Exception in " + plugin.getPluginKey() + "'s onPacketReceived()"); //try { Log.e("KDE/Device", "NetworkPacket:" + np.serialize()); } catch (Exception _) { } } } } else { Log.w("Device", "Ignoring packet with type " + np.getType() + " because no plugin can handle it"); } } else { //Log.e("KDE/onPacketReceived","Device not paired, will pass package to unpairedPacketListeners"); // If it is pair package, it should be captured by "if" at start // If not and device is paired, it should be captured by isPaired // Else unpair, this handles the situation when one device unpairs, but other dont know like unpairing when wi-fi is off unpair(); //If capabilities are not supported, iterate all plugins Collection targetPlugins = pluginsByIncomingInterface.get(np.getType()); if (targetPlugins != null && !targetPlugins.isEmpty()) { for (String pluginKey : targetPlugins) { Plugin plugin = plugins.get(pluginKey); try { plugin.onUnpairedDevicePacketReceived(np); } catch (Exception e) { e.printStackTrace(); Log.e("KDE/Device", "Exception in " + plugin.getDisplayName() + "'s onPacketReceived() in unPairedPacketListeners"); } } } else { Log.e("Device", "Ignoring packet with type " + np.getType() + " because no plugin can handle it"); } } } public static abstract class SendPacketStatusCallback { public abstract void onSuccess(); public abstract void onFailure(Throwable e); public void onProgressChanged(int percent) { } } private SendPacketStatusCallback defaultCallback = new SendPacketStatusCallback() { @Override public void onSuccess() { } @Override public void onFailure(Throwable e) { if (e != null) { e.printStackTrace(); } else { Log.e("KDE/sendPacket", "Unknown (null) exception"); } } }; public void sendPacket(NetworkPacket np) { sendPacket(np, defaultCallback); } public boolean sendPacketBlocking(NetworkPacket np) { return sendPacketBlocking(np, defaultCallback); } //Async public void sendPacket(final NetworkPacket np, final SendPacketStatusCallback callback) { new Thread(() -> sendPacketBlocking(np, callback)).start(); } public boolean sendPacketBlocking(final NetworkPacket np, final SendPacketStatusCallback callback) { /* if (!m_outgoingCapabilities.contains(np.getType()) && !NetworkPacket.protocolPacketTypes.contains(np.getType())) { Log.e("Device/sendPacket", "Plugin tried to send an undeclared package: " + np.getType()); Log.w("Device/sendPacket", "Declared outgoing package types: " + Arrays.toString(m_outgoingCapabilities.toArray())); } */ hackToMakeRetrocompatiblePacketTypes(np); boolean useEncryption = (protocolVersion < LanLinkProvider.MIN_VERSION_WITH_SSL_SUPPORT && (!np.getType().equals(NetworkPacket.PACKET_TYPE_PAIR) && isPaired())); boolean success = false; //Make a copy to avoid concurrent modification exception if the original list changes for (final BaseLink link : links) { if (link == null) continue; //Since we made a copy, maybe somebody destroyed the link in the meanwhile if (useEncryption) { success = link.sendPacketEncrypted(np, callback, publicKey); } else { success = link.sendPacket(np, callback); } if (success) break; //If the link didn't call sendSuccess(), try the next one } if (!success) { Log.e("KDE/sendPacket", "No device link (of " + links.size() + " available) could send the package. Packet " + np.getType() + " to " + name + " lost!"); } return success; } // // Plugin-related functions // public T getPlugin(Class pluginClass) { return (T) getPlugin(Plugin.getPluginKey(pluginClass)); } public T getPlugin(Class pluginClass, boolean includeFailed) { return (T) getPlugin(Plugin.getPluginKey(pluginClass), includeFailed); } public Plugin getPlugin(String pluginKey) { return getPlugin(pluginKey, false); } public Plugin getPlugin(String pluginKey, boolean includeFailed) { Plugin plugin = plugins.get(pluginKey); if (includeFailed && plugin == null) { plugin = failedPlugins.get(pluginKey); } return plugin; } private synchronized boolean addPlugin(final String pluginKey) { Plugin existing = plugins.get(pluginKey); if (existing != null) { if (existing.getMinSdk() > Build.VERSION.SDK_INT) { Log.i("KDE/addPlugin", "Min API level not fulfilled " + pluginKey); return false; } //Log.w("KDE/addPlugin","plugin already present:" + pluginKey); if (existing.checkOptionalPermissions()) { Log.i("KDE/addPlugin", "Optional Permissions OK " + pluginKey); pluginsWithoutOptionalPermissions.remove(pluginKey); } else { Log.e("KDE/addPlugin", "No optional permission " + pluginKey); pluginsWithoutOptionalPermissions.put(pluginKey, existing); } return true; } final Plugin plugin = PluginFactory.instantiatePluginForDevice(context, pluginKey, this); if (plugin == null) { Log.e("KDE/addPlugin", "could not instantiate plugin: " + pluginKey); //Can't put a null //failedPlugins.put(pluginKey, null); return false; } if (plugin.getMinSdk() > Build.VERSION.SDK_INT) { Log.i("KDE/addPlugin", "Min API level not fulfilled" + pluginKey); return false; } boolean success; try { success = plugin.onCreate(); } catch (Exception e) { success = false; e.printStackTrace(); Log.e("KDE/addPlugin", "Exception loading plugin " + pluginKey); } if (success) { //Log.e("addPlugin","added " + pluginKey); failedPlugins.remove(pluginKey); plugins.put(pluginKey, plugin); } else { Log.e("KDE/addPlugin", "plugin failed to load " + pluginKey); plugins.remove(pluginKey); failedPlugins.put(pluginKey, plugin); } if (!plugin.checkRequiredPermissions()) { Log.e("KDE/addPlugin", "No permission " + pluginKey); plugins.remove(pluginKey); pluginsWithoutPermissions.put(pluginKey, plugin); success = false; } else { Log.i("KDE/addPlugin", "Permissions OK " + pluginKey); pluginsWithoutPermissions.remove(pluginKey); if (plugin.checkOptionalPermissions()) { Log.i("KDE/addPlugin", "Optional Permissions OK " + pluginKey); pluginsWithoutOptionalPermissions.remove(pluginKey); } else { Log.e("KDE/addPlugin", "No optional permission " + pluginKey); pluginsWithoutOptionalPermissions.put(pluginKey, plugin); } } return success; } private synchronized boolean removePlugin(String pluginKey) { Plugin plugin = plugins.remove(pluginKey); Plugin failedPlugin = failedPlugins.remove(pluginKey); if (plugin == null) { if (failedPlugin == null) { //Not found return false; } plugin = failedPlugin; } try { plugin.onDestroy(); //Log.e("removePlugin","removed " + pluginKey); } catch (Exception e) { e.printStackTrace(); Log.e("KDE/removePlugin", "Exception calling onDestroy for plugin " + pluginKey); } return true; } public void setPluginEnabled(String pluginKey, boolean value) { settings.edit().putBoolean(pluginKey, value).apply(); reloadPluginsFromSettings(); } public boolean isPluginEnabled(String pluginKey) { boolean enabledByDefault = PluginFactory.getPluginInfo(context, pluginKey).isEnabledByDefault(); return settings.getBoolean(pluginKey, enabledByDefault); } public void reloadPluginsFromSettings() { failedPlugins.clear(); HashMap> newPluginsByIncomingInterface = new HashMap<>(); for (String pluginKey : m_supportedPlugins) { PluginFactory.PluginInfo pluginInfo = PluginFactory.getPluginInfo(context, pluginKey); boolean pluginEnabled = false; boolean listenToUnpaired = pluginInfo.listenToUnpaired(); if ((isPaired() || listenToUnpaired) && isReachable()) { pluginEnabled = isPluginEnabled(pluginKey); } if (pluginEnabled) { boolean success = addPlugin(pluginKey); if (success) { for (String packageType : pluginInfo.getSupportedPacketTypes()) { packageType = hackToMakeRetrocompatiblePacketTypes(packageType); ArrayList plugins = newPluginsByIncomingInterface.get(packageType); if (plugins == null) plugins = new ArrayList<>(); plugins.add(pluginKey); newPluginsByIncomingInterface.put(packageType, plugins); } } } else { removePlugin(pluginKey); } } pluginsByIncomingInterface = newPluginsByIncomingInterface; onPluginsChanged(); } public void onPluginsChanged() { for (PluginsChangedListener listener : pluginsChangedListeners) { listener.onPluginsChanged(Device.this); } } public ConcurrentHashMap getLoadedPlugins() { return plugins; } public ConcurrentHashMap getFailedPlugins() { return failedPlugins; } public ConcurrentHashMap getPluginsWithoutPermissions() { return pluginsWithoutPermissions; } public ConcurrentHashMap getPluginsWithoutOptionalPermissions() { return pluginsWithoutOptionalPermissions; } public void addPluginsChangedListener(PluginsChangedListener listener) { pluginsChangedListeners.add(listener); } public void removePluginsChangedListener(PluginsChangedListener listener) { pluginsChangedListeners.remove(listener); } public void disconnect() { for (BaseLink link : links) { link.disconnect(); } } public boolean deviceShouldBeKeptAlive() { SharedPreferences preferences = context.getSharedPreferences("trusted_devices", Context.MODE_PRIVATE); if (preferences.contains(getDeviceId())) { //Log.e("DeviceShouldBeKeptAlive", "because it's a paired device"); return true; //Already paired } for (BaseLink l : links) { if (l.linkShouldBeKeptAlive()) { return true; } } return false; } public List getSupportedPlugins() { return m_supportedPlugins; } public void hackToMakeRetrocompatiblePacketTypes(NetworkPacket np) { if (protocolVersion >= 6) return; np.mType = np.getType().replace(".request", ""); } public String hackToMakeRetrocompatiblePacketTypes(String type) { if (protocolVersion >= 6) return type; return type.replace(".request", ""); } } diff --git a/src/org/kde/kdeconnect/Helpers/NotificationHelper.java b/src/org/kde/kdeconnect/Helpers/NotificationHelper.java index 6702b6ac..2bf53ed7 100644 --- a/src/org/kde/kdeconnect/Helpers/NotificationHelper.java +++ b/src/org/kde/kdeconnect/Helpers/NotificationHelper.java @@ -1,25 +1,44 @@ package org.kde.kdeconnect.Helpers; import android.app.Notification; +import android.app.NotificationChannel; import android.app.NotificationManager; public class NotificationHelper { + private static NotificationChannel defaultChannel; + public static void notifyCompat(NotificationManager notificationManager, int notificationId, Notification notification) { try { notificationManager.notify(notificationId, notification); } catch (Exception e) { //4.1 will throw an exception about not having the VIBRATE permission, ignore it. //https://android.googlesource.com/platform/frameworks/base/+/android-4.2.1_r1.2%5E%5E!/ } } public static void notifyCompat(NotificationManager notificationManager, String tag, int notificationId, Notification notification) { try { notificationManager.notify(tag, notificationId, notification); } catch (Exception e) { //4.1 will throw an exception about not having the VIBRATE permission, ignore it. //https://android.googlesource.com/platform/frameworks/base/+/android-4.2.1_r1.2%5E%5E!/ } } + + public static String getDefaultChannelId(NotificationManager manager) { + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + if (defaultChannel == null) { + String id = "default"; + CharSequence name = "KDE Connect"; + int importance = NotificationManager.IMPORTANCE_DEFAULT; + defaultChannel = new NotificationChannel(id, name, importance); + manager.createNotificationChannel(defaultChannel); + } + return defaultChannel.getId(); + } + return null; + } + } diff --git a/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java b/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java index b3ebc50c..16d74dce 100644 --- a/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java @@ -1,131 +1,132 @@ /* * Copyright 2014 Albert Vaca Cintora * * 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.PingPlugin; import android.app.Activity; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.util.Log; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect_tp.R; public class PingPlugin extends Plugin { public final static String PACKET_TYPE_PING = "kdeconnect.ping"; @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_ping); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_ping_desc); } @Override public boolean onPacketReceived(NetworkPacket np) { if (!np.getType().equals(PACKET_TYPE_PING)) { Log.e("PingPlugin", "Ping plugin should not receive packets other than pings!"); return false; } //Log.e("PingPacketReceiver", "was a ping!"); TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); stackBuilder.addParentStack(MainActivity.class); stackBuilder.addNextIntent(new Intent(context, MainActivity.class)); PendingIntent resultPendingIntent = stackBuilder.getPendingIntent( 0, PendingIntent.FLAG_UPDATE_CURRENT ); int id; String message; if (np.has("message")) { message = np.getString("message"); id = (int) System.currentTimeMillis(); } else { message = "Ping!"; id = 42; //A unique id to create only one notification } - Notification noti = new NotificationCompat.Builder(context) + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + Notification noti = new NotificationCompat.Builder(context, NotificationHelper.getDefaultChannelId(notificationManager)) .setContentTitle(device.getName()) .setContentText(message) .setContentIntent(resultPendingIntent) .setTicker(message) .setSmallIcon(R.drawable.ic_notification) .setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL) .build(); - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationHelper.notifyCompat(notificationManager, id, noti); return true; } @Override public String getActionName() { return context.getString(R.string.send_ping); } @Override public void startMainActivity(Activity activity) { if (device != null) { device.sendPacket(new NetworkPacket(PACKET_TYPE_PING)); } } @Override public boolean hasMainActivity() { return true; } @Override public boolean displayInContextMenu() { return true; } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_PING}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_PING}; } } diff --git a/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java b/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java index 83929e99..cb2ff707 100644 --- a/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java @@ -1,138 +1,140 @@ /* * Copyright 2014 Albert Vaca Cintora * * 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.ReceiveNotificationsPlugin; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.util.Log; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect_tp.R; import java.io.InputStream; public class ReceiveNotificationsPlugin extends Plugin { public final static String PACKET_TYPE_NOTIFICATION = "kdeconnect.notification"; public final static String PACKET_TYPE_NOTIFICATION_REQUEST = "kdeconnect.notification.request"; @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_receive_notifications); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_receive_notifications_desc); } @Override public boolean isEnabledByDefault() { return false; } @Override public boolean onCreate() { // request all existing notifications NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION_REQUEST); np.set("request", true); device.sendPacket(np); return true; } @Override public boolean onPacketReceived(final NetworkPacket np) { if (!np.has("ticker") || !np.has("appName") || !np.has("id")) { Log.e("NotificationsPlugin", "Received notification package lacks properties"); } else { TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); stackBuilder.addParentStack(MainActivity.class); stackBuilder.addNextIntent(new Intent(context, MainActivity.class)); PendingIntent resultPendingIntent = stackBuilder.getPendingIntent( 0, PendingIntent.FLAG_UPDATE_CURRENT ); Bitmap largeIcon = null; if (np.hasPayload()) { int width = 64; // default icon dimensions int height = 64; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { width = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); height = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); } final InputStream input = np.getPayload(); largeIcon = BitmapFactory.decodeStream(np.getPayload()); try { input.close(); } catch (Exception e) { } if (largeIcon != null) { //Log.i("NotificationsPlugin", "hasPayload: size=" + largeIcon.getWidth() + "/" + largeIcon.getHeight() + " opti=" + width + "/" + height); if (largeIcon.getWidth() > width || largeIcon.getHeight() > height) { // older API levels don't scale notification icons automatically, therefore: largeIcon = Bitmap.createScaledBitmap(largeIcon, width, height, false); } } } - Notification noti = new NotificationCompat.Builder(context) + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + Notification noti = new NotificationCompat.Builder(context, NotificationHelper.getDefaultChannelId(notificationManager)) .setContentTitle(np.getString("appName")) .setContentText(np.getString("ticker")) .setContentIntent(resultPendingIntent) .setTicker(np.getString("ticker")) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(largeIcon) .setAutoCancel(true) .setLocalOnly(true) // to avoid bouncing the notification back to other kdeconnect nodes .setDefaults(Notification.DEFAULT_ALL) .build(); - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationHelper.notifyCompat(notificationManager, "kdeconnectId:" + np.getString("id", "0"), np.getInt("id", 0), noti); } return true; } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION_REQUEST}; } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/NotificationUpdateCallback.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/NotificationUpdateCallback.java index a5746025..437b4b85 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/NotificationUpdateCallback.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/NotificationUpdateCallback.java @@ -1,118 +1,119 @@ package org.kde.kdeconnect.Plugins.SharePlugin; import android.app.NotificationManager; import android.content.Context; import android.content.res.Resources; import android.support.v4.app.NotificationCompat; 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; class NotificationUpdateCallback extends Device.SendPacketStatusCallback { final Context context; final Resources res; final Device device; final NotificationManager notificationManager; final NotificationCompat.Builder builder; final ArrayList toSend; final int notificationId; int sentFiles = 0; final int numFiles; NotificationUpdateCallback(Context context, Device device, ArrayList toSend) { this.context = context; 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) + + notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE); + builder = new NotificationCompat.Builder(context, NotificationHelper.getDefaultChannelId(notificationManager)) .setSmallIcon(android.R.drawable.stat_sys_upload) .setAutoCancel(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()); if (e != null) { e.printStackTrace(); } } 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 } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java index 3208ea45..a68c2c5b 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java @@ -1,152 +1,152 @@ 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 android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.support.v4.content.FileProvider; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect_tp.R; import java.io.File; import java.io.FileNotFoundException; public class ShareNotification { private final String filename; private NotificationManager notificationManager; private int notificationId; private NotificationCompat.Builder builder; private Device device; public ShareNotification(Device device, String filename) { this.device = device; this.filename = filename; notificationId = (int) System.currentTimeMillis(); notificationManager = (NotificationManager) device.getContext().getSystemService(Context.NOTIFICATION_SERVICE); - builder = new NotificationCompat.Builder(device.getContext()) + builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.getDefaultChannelId(notificationManager)) .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) .setProgress(100, 0, true); } public void show() { NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); } public int getId() { 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 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()); - builder = new NotificationCompat.Builder(device.getContext()); + builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.getDefaultChannelId(notificationManager)); 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) { /* * 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/")) { try { Bitmap image = BitmapFactory.decodeStream(device.getContext().getContentResolver().openInputStream(destinationUri)); if (image != null) { builder.setLargeIcon(image); builder.setStyle(new NotificationCompat.BigPictureStyle() .bigPicture(image)); } } catch (FileNotFoundException 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); TaskStackBuilder stackBuilder = TaskStackBuilder.create(device.getContext()); stackBuilder.addNextIntent(intent); PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, 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(), 0, 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()); } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java index cb3b66dc..b30d9a2c 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java @@ -1,464 +1,465 @@ /* * Copyright 2014 Albert Vaca Cintora * * 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.Manifest; import android.app.Activity; import android.app.DownloadManager; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ClipboardManager; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; 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.provider.MediaStore; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.support.v4.content.ContextCompat; 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.SettingsActivity; import org.kde.kdeconnect_tp.R; import java.io.File; import java.io.FileNotFoundException; import java.io.InputStream; import java.io.OutputStream; import java.net.URL; import java.util.ArrayList; public class SharePlugin extends Plugin { public final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request"; final static boolean openUrlsDirectly = true; @Override public boolean onCreate() { optionalPermissionExplanation = R.string.share_optional_permission_explanation; return true; } @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_sharereceiver); } @Override public Drawable getIcon() { return ContextCompat.getDrawable(context, R.drawable.share_plugin_action); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_sharereceiver_desc); } @Override public boolean hasMainActivity() { return true; } @Override public String getActionName() { return context.getString(R.string.send_files); } @Override public void startMainActivity(Activity parentActivity) { Intent intent = new Intent(parentActivity, SendFileActivity.class); intent.putExtra("deviceId", device.getDeviceId()); parentActivity.startActivity(intent); } @Override public boolean hasSettings() { return true; } @Override public boolean onPacketReceived(NetworkPacket np) { try { if (np.hasPayload()) { Log.i("SharePlugin", "hasPayload"); if (isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { receiveFile(np); } else { Log.i("SharePlugin", "no Permission for Storage"); } } else if (np.has("text")) { Log.i("SharePlugin", "hasText"); receiveText(np); } else if (np.has("url")) { receiveUrl(np); } else { Log.e("SharePlugin", "Error: Nothing attached!"); } } catch (Exception e) { Log.e("SharePlugin", "Exception"); e.printStackTrace(); } return true; } private void receiveUrl(NetworkPacket np) { String url = np.getString("url"); Log.i("SharePlugin", "hasUrl: " + url); Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); if (openUrlsDirectly) { context.startActivity(browserIntent); } else { Resources res = context.getResources(); TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); stackBuilder.addNextIntent(browserIntent); PendingIntent resultPendingIntent = stackBuilder.getPendingIntent( 0, PendingIntent.FLAG_UPDATE_CURRENT ); - Notification noti = new NotificationCompat.Builder(context) + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + Notification noti = new NotificationCompat.Builder(context, NotificationHelper.getDefaultChannelId(notificationManager)) .setContentTitle(res.getString(R.string.received_url_title, device.getName())) .setContentText(res.getString(R.string.received_url_text, url)) .setContentIntent(resultPendingIntent) .setTicker(res.getString(R.string.received_url_title, device.getName())) .setSmallIcon(R.drawable.ic_notification) .setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL) .build(); - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationHelper.notifyCompat(notificationManager, (int) System.currentTimeMillis(), noti); } } private void receiveText(NetworkPacket np) { String text = np.getString("text"); if (Build.VERSION.SDK_INT >= 11) { ClipboardManager cm = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); cm.setText(text); } else { android.text.ClipboardManager clipboard = (android.text.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); clipboard.setText(text); } Toast.makeText(context, R.string.shareplugin_text_saved, Toast.LENGTH_LONG).show(); } private void receiveFile(NetworkPacket np) { final InputStream input = np.getPayload(); final long fileLength = np.getPayloadSize(); final String originalFilename = np.getString("filename", Long.toString(System.currentTimeMillis())); //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. final boolean customDestination = ShareSettingsActivity.isCustomDestinationEnabled(context); final String defaultPath = ShareSettingsActivity.getDefaultDestinationDirectory().getAbsolutePath(); final String filename = customDestination ? originalFilename : FilesHelper.findNonExistingNameForNewFile(defaultPath, originalFilename); String displayName = FilesHelper.getFileNameWithoutExt(filename); final String mimeType = FilesHelper.getMimeTypeFromFile(filename); if ("*/*".equals(mimeType)) { displayName = filename; } final DocumentFile destinationFolderDocument = ShareSettingsActivity.getDestinationDirectory(context); final DocumentFile destinationDocument = destinationFolderDocument.createFile(mimeType, displayName); final OutputStream destinationOutput; try { destinationOutput = context.getContentResolver().openOutputStream(destinationDocument.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()); //Update the notification and allow to open the file from it notification.setFinished(true); notification.setURI(destinationUri, mimeType); notification.show(); if (!customDestination && Build.VERSION.SDK_INT >= 12) { 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(); } @Override public void startPreferencesActivity(SettingsActivity parentActivity) { Intent intent = new Intent(parentActivity, ShareSettingsActivity.class); intent.putExtra("plugin_display_name", getDisplayName()); intent.putExtra("plugin_key", getPluginKey()); parentActivity.startActivity(intent); } static void queuedSendUriList(Context context, final Device device, final ArrayList uriList) { //Read all the data early, as we only have permissions to do it while the activity is alive final ArrayList toSend = new ArrayList<>(); for (Uri uri : uriList) { toSend.add(uriToNetworkPacket(context, uri)); } //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) { e.printStackTrace(); } }).start(); } //Create the network package from the URI private static NetworkPacket uriToNetworkPacket(final Context context, final Uri uri) { try { ContentResolver cr = context.getContentResolver(); InputStream inputStream = cr.openInputStream(uri); NetworkPacket np = new NetworkPacket(PACKET_TYPE_SHARE_REQUEST); long size = -1; if (uri.getScheme().equals("file")) { // file:// is a non media uri, so we cannot query the ContentProvider np.set("filename", uri.getLastPathSegment()); try { size = new File(uri.getPath()).length(); } catch (Exception e) { Log.e("SendFileActivity", "Could not obtain file size"); e.printStackTrace(); } } else { // Probably a content:// uri, so we query the Media content provider Cursor cursor = null; try { String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.DISPLAY_NAME}; cursor = cr.query(uri, proj, null, null, null); int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); cursor.moveToFirst(); String path = cursor.getString(column_index); np.set("filename", Uri.parse(path).getLastPathSegment()); size = new File(path).length(); } catch (Exception unused) { Log.w("SendFileActivity", "Could not resolve media to a file, trying to get info as media"); try { int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME); cursor.moveToFirst(); String name = cursor.getString(column_index); np.set("filename", name); } catch (Exception e) { e.printStackTrace(); Log.e("SendFileActivity", "Could not obtain file name"); } try { int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE); cursor.moveToFirst(); //For some reason this size can differ from the actual file size! size = cursor.getInt(column_index); } catch (Exception e) { Log.e("SendFileActivity", "Could not obtain file size"); e.printStackTrace(); } } finally { try { cursor.close(); } catch (Exception e) { } } } np.setPayload(inputStream, size); return np; } catch (Exception e) { Log.e("SendFileActivity", "Exception sending files"); e.printStackTrace(); return null; } } public static void share(Intent intent, Device device) { Bundle extras = intent.getExtras(); if (extras != null) { if (extras.containsKey(Intent.EXTRA_STREAM)) { try { ArrayList uriList; if (!Intent.ACTION_SEND.equals(intent.getAction())) { uriList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); } else { Uri uri = extras.getParcelable(Intent.EXTRA_STREAM); uriList = new ArrayList<>(); uriList.add(uri); } SharePlugin.queuedSendUriList(device.getContext(), device, uriList); } catch (Exception e) { Log.e("ShareActivity", "Exception"); e.printStackTrace(); } } else if (extras.containsKey(Intent.EXTRA_TEXT)) { String text = extras.getString(Intent.EXTRA_TEXT); String subject = extras.getString(Intent.EXTRA_SUBJECT); //Hack: Detect shared youtube videos, so we can open them in the browser instead of as text if (subject != null && subject.endsWith("YouTube")) { int index = text.indexOf(": http://youtu.be/"); if (index > 0) { text = text.substring(index + 2); //Skip ": " } } boolean isUrl; try { new URL(text); isUrl = true; } catch (Exception e) { isUrl = false; } NetworkPacket np = new NetworkPacket(SharePlugin.PACKET_TYPE_SHARE_REQUEST); if (isUrl) { np.set("url", text); } else { np.set("text", text); } device.sendPacket(np); } } } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_SHARE_REQUEST}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_SHARE_REQUEST}; } @Override public String[] getOptionalPermissions() { return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; } }