diff --git a/res/values/strings.xml b/res/values/strings.xml index 566cfb1e..2e2cd1ac 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1,329 +1,328 @@ KDE Connect Not connected to any device Connected to: %s Telephony notifier Send notifications for incoming calls Battery report Periodically report battery status Filesystem expose Allows to browse this device\'s filesystem remotely Clipboard sync Share the clipboard content Remote input Use your phone or tablet as a touchpad and keyboard Presentation remote Use your device to change slides in a presentation Receive remote keypresses Receive keypress events from remote devices Multimedia controls Provides a remote control for your media player Run Command Trigger remote commands from your phone or tablet Contacts Synchronizer Allow synchronizing the device\'s contacts book Ping Send and receive pings Notification sync Access your notifications from other devices Receive notifications Receive notifications from the other device and display them on Android Share and receive Share files and URLs between devices This feature is not available in your Android version No devices OK Cancel Open settings You need to grant permission to access notifications To be able to control your media players you need to grant access to the notifications Send ping Multimedia control remotekeyboard_editing_only Handle remote keys only when editing There is no active remote keyboard connection, establish one in kdeconnect Remote keyboard connection is active There is more than one remote keyboard connection, select the device to configure Remote input Move a finger on the screen to move the mouse cursor. Tap for a click, and use two/three fingers for right and middle buttons. Use 2 fingers to scroll. Use a long press to drag\'n drop. Set two finger tap action Set three finger tap action Set touchpad sensitivity Set pointer acceleration mousepad_double_tap_key mousepad_triple_tap_key mousepad_sensitivity_key mousepad_acceleration_profile_key Reverse Scrolling Direction mousepad_scroll_direction Right click Middle click Nothing right middle default medium right middle none Slowest Above Slowest Default Above Default Fastest No Acceleration Weakest Weaker Medium Stronger Strongest slowest aboveSlowest default aboveDefault fastest noacceleration weaker weak medium strong stronger Connected devices Available devices Remembered devices - Plugins failed to load (tap for more info): Plugin settings Unpair Paired device not reachable Pair new device Unknown device Device not reachable Pairing already requested Device already paired Could not send package Timed out Canceled by user Canceled by other peer Invalid key received Encryption Info The other device doesn\'t use a recent version of KDE Connect, using the legacy encryption method. SHA1 fingerprint of your device certificate is: SHA1 fingerprint of remote device certificate is: Pair requested Pairing request from %1s Received link from %1s Tap to open \'%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 %1$s Received %2$d files from %1$s Failed receiving file from %1$s Failed receiving %2$d of %3$d files from %1$s Tap to open \'%1s\' Cannot create file %s Sent file to %1s %1s Failed to send file to %1s %1s Tap to answer Reconnect Send Right Click Send Middle Click Show Keyboard Device not paired Request pairing Accept Reject Device Pair device Settings Play Pause Previous Rewind Fast-forward Next Volume Multimedia Settings Forward/rewind buttons Adjust the time to fast forward/rewind when pressed mpris_interval_time 10 seconds 20 seconds 30 seconds 1 minute 2 minutes 10000000 10000000 20000000 30000000 60000000 120000000 Show media control notification Allow controlling your media players without opening KDE Connect mpris_notification_enabled Share To… This device uses an old protocol version This device uses a newer protocol version General Settings Settings %s settings Device name %s Invalid device name Received text, saved to clipboard Custom device list Pair a new device Unpair %s Add devices by IP Delete %s? Noisy notifications Vibrate and play a sound when receiving a file Customize destination directory Received files will appear in Downloads Files will be stored in the directory below Destination directory Share Share \"%s\" Notification filter Notifications will be synchronized for the selected apps. Internal storage All files SD card %d SD card (read only) Camera pictures Add host/IP Hostname or IP No players found Use this option only if your device is not automatically detected. Enter IP address or hostname below and touch the button to add it to the list. Touch an existing item to remove it from the list. %1$s on %2$s Send files KDE Connect Devices Other devices running KDE Connect in your same network should appear here. Device paired Rename device Rename Refresh This paired device is not reachable. Make sure it is connected to your same network. It looks like you are on a mobile data connection. KDE Connect only works on local networks. There are no file browsers installed. Send SMS Send text messages from your desktop This plugin is not supported by the device Find my phone Find my tablet Find my TV Rings this device so you can find it Found Open Close You need to grant permissions to access the storage Some Plugins need permissions to work (tap for more info): This plugin needs permissions to work You need to grant extra permissions to enable all functions Some plugins have features disabled because of lack of permission (tap for more info): To access your files from your PC the app needs permission to access your phone\'s storage To share files between your phone and your desktop you need to give access to the phone\'s storage To read and write SMS from your desktop you need to give permission to SMS To see phone calls and SMS from the desktop you need to give permission to phone calls and SMS To see a contact name instead of a phone number you need to give access to the phone\'s contacts To share your contacts book with the desktop, you need to give contacts permission Select a ringtone Blocked numbers Don\'t show calls and SMS from these numbers. Please specify one number per line Cover art of current media Device icon Settings icon Fullscreen Exit presentation You can lock your device to use the volume keys as previous/next buttons Add a command There are no commands registered You can add new commands in the KDE Connect System Settings You can add commands on the desktop Media Player Control Control your phones media players from another device Dark theme Other notifications Persistent indicator Media control File transfer Stop the current player Copy URL to clipboard Copied to clipboard Device is not reachable Device is not paired There is no such device This device does not have the Run Command Plugin enabled Find remote device Ring your remote device Ring System volume Control the system volume of the remote device Mute All Devices Device name Dark theme More settings Per-device settings can be found under \'Plugin settings\' from within a device. Show persistent notification Persistent notification Tap to enable/disable in Notification settings Extra options Privacy options Set your privacy options New notification Block contents of notifications Block images in notifications Notifications from other devices findmyphone_ringtone diff --git a/src/org/kde/kdeconnect/Device.java b/src/org/kde/kdeconnect/Device.java index 36c1ec79..aa5d0347 100644 --- a/src/org/kde/kdeconnect/Device.java +++ b/src/org/kde/kdeconnect/Device.java @@ -1,919 +1,888 @@ /* * 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.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; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; 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 final 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; 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(MainActivity.EXTRA_DEVICE_ID, getDeviceId()); intent.putExtra(MainActivity.PAIR_REQUEST_STATUS, MainActivity.PAIRING_PENDING); PendingIntent pendingIntent = PendingIntent.getActivity(getContext(), 1, intent, PendingIntent.FLAG_CANCEL_CURRENT); Intent acceptIntent = new Intent(getContext(), MainActivity.class); Intent rejectIntent = new Intent(getContext(), MainActivity.class); acceptIntent.putExtra(MainActivity.EXTRA_DEVICE_ID, getDeviceId()); //acceptIntent.putExtra("notificationId", notificationId); acceptIntent.putExtra(MainActivity.PAIR_REQUEST_STATUS, MainActivity.PAIRING_ACCEPTED); rejectIntent.putExtra(MainActivity.EXTRA_DEVICE_ID, getDeviceId()); //rejectIntent.putExtra("notificationId", notificationId); 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(); final NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); Notification noti = new NotificationCompat.Builder(getContext(), NotificationHelper.Channels.DEFAULT) .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(); 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(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 final 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)); + Plugin plugin = getPlugin(Plugin.getPluginKey(pluginClass)); + return (T) plugin; } - public T getPlugin(Class pluginClass, boolean includeFailed) { - return (T) getPlugin(Plugin.getPluginKey(pluginClass), includeFailed); - } - - private 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; + public Plugin getPlugin(String pluginKey) { + return plugins.get(pluginKey); } 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 { + if (!success) { Log.e("KDE/addPlugin", "plugin failed to load " + pluginKey); - plugins.remove(pluginKey); - failedPlugins.put(pluginKey, plugin); } + plugins.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; + return false; } 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(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(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; } private void hackToMakeRetrocompatiblePacketTypes(NetworkPacket np) { if (protocolVersion >= 6) return; np.mType = np.getType().replace(".request", ""); } private String hackToMakeRetrocompatiblePacketTypes(String type) { if (protocolVersion >= 6) return type; return type.replace(".request", ""); } } diff --git a/src/org/kde/kdeconnect/Plugins/MprisReceiverPlugin/MprisReceiverPlugin.java b/src/org/kde/kdeconnect/Plugins/MprisReceiverPlugin/MprisReceiverPlugin.java index f230414c..2a58344e 100644 --- a/src/org/kde/kdeconnect/Plugins/MprisReceiverPlugin/MprisReceiverPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/MprisReceiverPlugin/MprisReceiverPlugin.java @@ -1,228 +1,234 @@ /* * Copyright 2018 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 . */ package org.kde.kdeconnect.Plugins.MprisReceiverPlugin; import android.content.ComponentName; import android.content.Context; import android.media.session.MediaController; import android.media.session.MediaSessionManager; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.provider.Settings; import android.util.Log; import org.kde.kdeconnect.Helpers.AppsHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.NotificationsPlugin.NotificationReceiver; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.AlertDialogFragment; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment; import org.kde.kdeconnect_tp.R; import java.util.HashMap; import java.util.List; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) //@PluginFactory.LoadablePlugin public class MprisReceiverPlugin extends Plugin implements MediaSessionManager.OnActiveSessionsChangedListener { private final static String PACKET_TYPE_MPRIS = "kdeconnect.mpris"; private final static String PACKET_TYPE_MPRIS_REQUEST = "kdeconnect.mpris.request"; private static final String TAG = "MprisReceiver"; private HashMap players; @Override public boolean onCreate() { if (!hasPermission()) return false; players = new HashMap<>(); try { MediaSessionManager manager = (MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE); if (null == manager) return false; manager.addOnActiveSessionsChangedListener(MprisReceiverPlugin.this, new ComponentName(context, NotificationReceiver.class), new Handler(Looper.getMainLooper())); createPlayers(manager.getActiveSessions(new ComponentName(context, NotificationReceiver.class))); sendPlayerList(); } catch (Exception e) { Log.e(TAG, "Exception", e); } return true; } private void createPlayers(List sessions) { for (MediaController controller : sessions) { createPlayer(controller); } } @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_mprisreceiver); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_mprisreceiver_desc); } @Override public boolean onPacketReceived(NetworkPacket np) { if (np.getBoolean("requestPlayerList")) { sendPlayerList(); return true; } if (!np.has("player")) { return false; } MprisReceiverPlayer player = players.get(np.getString("player")); if (null == player) { return false; } if (np.getBoolean("requestNowPlaying", false)) { sendMetadata(player); return true; } if (np.has("action")) { String action = np.getString("action"); switch (action) { case "PlayPause": player.playPause(); break; case "Next": player.next(); break; case "Previous": player.previous(); } } return true; } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_MPRIS_REQUEST}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_MPRIS}; } @Override public void onActiveSessionsChanged(@Nullable List controllers) { if (null == controllers) { return; } players.clear(); createPlayers(controllers); sendPlayerList(); } private void createPlayer(MediaController controller) { MprisReceiverPlayer player = new MprisReceiverPlayer(controller, AppsHelper.appNameLookup(context, controller.getPackageName())); controller.registerCallback(new MprisReceiverCallback(this, player), new Handler(Looper.getMainLooper())); players.put(player.getName(), player); } private void sendPlayerList() { NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS); np.set("playerList", players.keySet()); device.sendPacket(np); } void sendPlaying(MprisReceiverPlayer player) { NetworkPacket np = new NetworkPacket(MprisReceiverPlugin.PACKET_TYPE_MPRIS); np.set("player", player.getName()); np.set("isPlaying", player.isPlaying()); device.sendPacket(np); } @Override public int getMinSdk() { return Build.VERSION_CODES.LOLLIPOP_MR1; } public void sendMetadata(MprisReceiverPlayer player) { NetworkPacket np = new NetworkPacket(MprisReceiverPlugin.PACKET_TYPE_MPRIS); np.set("player", player.getName()); if (player.getArtist().isEmpty()) { np.set("nowPlaying", player.getTitle()); } else { np.set("nowPlaying", player.getArtist() + " - " + player.getTitle()); } np.set("title", player.getTitle()); np.set("artist", player.getArtist()); np.set("album", player.getAlbum()); np.set("isPlaying", player.isPlaying()); np.set("pos", player.getPosition()); device.sendPacket(np); } public void sendVolume(MprisReceiverPlayer player) { NetworkPacket np = new NetworkPacket(MprisReceiverPlugin.PACKET_TYPE_MPRIS); np.set("player", player.getName()); np.set("volume", player.getVolume()); device.sendPacket(np); } @Override - public AlertDialogFragment getErrorDialog() { + public boolean checkRequiredPermissions() { + //Notifications use a different kind of permission, because it was added before the current runtime permissions model + return hasPermission(); + } + + @Override + public AlertDialogFragment getPermissionExplanationDialog(int requestCode) { return new StartActivityAlertDialogFragment.Builder() .setTitle(R.string.pref_plugin_mpris) .setMessage(R.string.no_permission_mprisreceiver) .setPositiveButton(R.string.open_settings) .setNegativeButton(R.string.cancel) .setIntentAction("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") .setStartForResult(true) - .setRequestCode(MainActivity.RESULT_NEEDS_RELOAD) + .setRequestCode(requestCode) .create(); } private boolean hasPermission() { String notificationListenerList = Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners"); return (notificationListenerList != null && notificationListenerList.contains(context.getPackageName())); } } diff --git a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java index 1d71bb33..92a925ba 100644 --- a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java @@ -1,588 +1,594 @@ /* * 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.NotificationsPlugin; import android.annotation.TargetApi; import android.app.Notification; import android.app.PendingIntent; import android.app.RemoteInput; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.service.notification.StatusBarNotification; import android.text.SpannableString; import android.util.Log; import org.kde.kdeconnect.Helpers.AppsHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.UserInterface.AlertDialogFragment; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment; import org.kde.kdeconnect_tp.R; import java.io.ByteArrayOutputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) @PluginFactory.LoadablePlugin public class NotificationsPlugin extends Plugin implements NotificationReceiver.NotificationListener { private final static String PACKET_TYPE_NOTIFICATION = "kdeconnect.notification"; private final static String PACKET_TYPE_NOTIFICATION_REQUEST = "kdeconnect.notification.request"; private final static String PACKET_TYPE_NOTIFICATION_REPLY = "kdeconnect.notification.reply"; private AppDatabase appDatabase; private Set currentNotifications; private Map pendingIntents; private boolean serviceReady; @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_notifications); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_notifications_desc); } @Override public boolean hasSettings() { return true; } @Override public PluginSettingsFragment getSettingsFragment() { if (hasPermission()) { Context context = device.getContext(); Intent intent = new Intent(context, NotificationFilterActivity.class); context.startActivity(intent); } return null; } + @Override + public boolean checkRequiredPermissions() { + //Notifications use a different kind of permission, because it was added before the current runtime permissions model + return hasPermission(); + } + private boolean hasPermission() { String notificationListenerList = Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners"); return (notificationListenerList != null && notificationListenerList.contains(context.getPackageName())); } @Override public boolean onCreate() { if (!hasPermission()) return false; pendingIntents = new HashMap<>(); currentNotifications = new HashSet<>(); appDatabase = new AppDatabase(context, true); NotificationReceiver.RunCommand(context, service -> { service.addListener(NotificationsPlugin.this); serviceReady = service.isConnected(); if (serviceReady) { sendCurrentNotifications(service); } }); return true; } @Override public void onDestroy() { NotificationReceiver.RunCommand(context, service -> service.removeListener(NotificationsPlugin.this)); } @Override public void onListenerConnected(NotificationReceiver service) { serviceReady = true; sendCurrentNotifications(service); } @Override public void onNotificationRemoved(StatusBarNotification statusBarNotification) { if (statusBarNotification == null) { Log.w("onNotificationRemoved", "notification is null"); return; } String id = getNotificationKeyCompat(statusBarNotification); NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION); np.set("id", id); np.set("isCancel", true); device.sendPacket(np); currentNotifications.remove(id); } @Override public void onNotificationPosted(StatusBarNotification statusBarNotification) { sendNotification(statusBarNotification); } private void sendNotification(StatusBarNotification statusBarNotification) { Notification notification = statusBarNotification.getNotification(); if ((notification.flags & Notification.FLAG_FOREGROUND_SERVICE) != 0 || (notification.flags & Notification.FLAG_ONGOING_EVENT) != 0 || (notification.flags & Notification.FLAG_LOCAL_ONLY) != 0 || (notification.flags & NotificationCompat.FLAG_GROUP_SUMMARY) != 0) //The notification that groups other notifications { //This is not a notification we want! return; } if (!appDatabase.isEnabled(statusBarNotification.getPackageName())) { return; // we dont want notification from this app } String key = getNotificationKeyCompat(statusBarNotification); String packageName = statusBarNotification.getPackageName(); String appName = AppsHelper.appNameLookup(context, packageName); if ("com.facebook.orca".equals(packageName) && (statusBarNotification.getId() == 10012) && "Messenger".equals(appName) && notification.tickerText == null) { //HACK: Hide weird Facebook empty "Messenger" notification that is actually not shown in the phone return; } if ("com.android.systemui".equals(packageName) && "low_battery".equals(statusBarNotification.getTag())) { //HACK: Android low battery notification are posted again every few seconds. Ignore them, as we already have a battery indicator. return; } if ("org.kde.kdeconnect_tp".equals(packageName)) { // Don't send our own notifications return; } NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION); boolean isUpdate = currentNotifications.contains(key); if (!isUpdate) { //If it's an update, the other end should have the icon already: no need to extract it and create the payload again try { Bitmap appIcon; Context foreignContext = context.createPackageContext(statusBarNotification.getPackageName(), 0); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { appIcon = iconToBitmap(foreignContext, notification.getLargeIcon()); } else { appIcon = notification.largeIcon; } if (appIcon == null) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { appIcon = iconToBitmap(foreignContext, notification.getSmallIcon()); } else { PackageManager pm = context.getPackageManager(); Resources foreignResources = pm.getResourcesForApplication(statusBarNotification.getPackageName()); Drawable foreignIcon = foreignResources.getDrawable(notification.icon); appIcon = drawableToBitmap(foreignIcon); } } if (appIcon != null && !appDatabase.getPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_IMAGES)) { ByteArrayOutputStream outStream = new ByteArrayOutputStream(); appIcon.compress(Bitmap.CompressFormat.PNG, 90, outStream); byte[] bitmapData = outStream.toByteArray(); Log.e("PAYLOAD", "PAYLOAD: " + getChecksum(bitmapData)); np.setPayload(new NetworkPacket.Payload(bitmapData)); np.set("payloadHash", getChecksum(bitmapData)); } } catch (Exception e) { e.printStackTrace(); Log.e("NotificationsPlugin", "Error retrieving icon"); } } else { currentNotifications.add(key); } np.set("id", key); np.set("isClearable", statusBarNotification.isClearable()); np.set("appName", appName == null ? packageName : appName); np.set("time", Long.toString(statusBarNotification.getPostTime())); if (!appDatabase.getPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_CONTENTS)) { RepliableNotification rn = extractRepliableNotification(statusBarNotification); if (rn.pendingIntent != null) { np.set("requestReplyId", rn.id); pendingIntents.put(rn.id, rn); } np.set("ticker", getTickerText(notification)); np.set("title", getNotificationTitle(notification)); np.set("text", getNotificationText(notification)); } device.sendPacket(np); } private Bitmap drawableToBitmap(Drawable drawable) { if (drawable == null) return null; Bitmap res; if (drawable.getIntrinsicWidth() > 128 || drawable.getIntrinsicHeight() > 128) { res = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); } else if (drawable.getIntrinsicWidth() <= 64 || drawable.getIntrinsicHeight() <= 64) { res = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); } else { res = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); } Canvas canvas = new Canvas(res); drawable.setBounds(0, 0, res.getWidth(), res.getHeight()); drawable.draw(canvas); return res; } @RequiresApi(Build.VERSION_CODES.M) private Bitmap iconToBitmap(Context foreignContext, Icon icon) { if (icon == null) return null; return drawableToBitmap(icon.loadDrawable(foreignContext)); } @RequiresApi(api = Build.VERSION_CODES.KITKAT_WATCH) private void replyToNotification(String id, String message) { if (pendingIntents.isEmpty() || !pendingIntents.containsKey(id)) { Log.e("NotificationsPlugin", "No such notification"); return; } RepliableNotification repliableNotification = pendingIntents.get(id); if (repliableNotification == null) { Log.e("NotificationsPlugin", "No such notification"); return; } RemoteInput[] remoteInputs = new RemoteInput[repliableNotification.remoteInputs.size()]; Intent localIntent = new Intent(); localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); Bundle localBundle = new Bundle(); int i = 0; for (RemoteInput remoteIn : repliableNotification.remoteInputs) { getDetailsOfNotification(remoteIn); remoteInputs[i] = remoteIn; localBundle.putCharSequence(remoteInputs[i].getResultKey(), message); i++; } RemoteInput.addResultsToIntent(remoteInputs, localIntent, localBundle); try { repliableNotification.pendingIntent.send(context, 0, localIntent); } catch (PendingIntent.CanceledException e) { Log.e("NotificationPlugin", "replyToNotification error: " + e.getMessage()); } pendingIntents.remove(id); } @RequiresApi(api = Build.VERSION_CODES.KITKAT_WATCH) private void getDetailsOfNotification(RemoteInput remoteInput) { //Some more details of RemoteInput... no idea what for but maybe it will be useful at some point String resultKey = remoteInput.getResultKey(); String label = remoteInput.getLabel().toString(); Boolean canFreeForm = remoteInput.getAllowFreeFormInput(); if (remoteInput.getChoices() != null && remoteInput.getChoices().length > 0) { String[] possibleChoices = new String[remoteInput.getChoices().length]; for (int i = 0; i < remoteInput.getChoices().length; i++) { possibleChoices[i] = remoteInput.getChoices()[i].toString(); } } } private String getNotificationTitle(Notification notification) { final String TITLE_KEY = "android.title"; final String TEXT_KEY = "android.text"; String title = ""; if (notification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { try { Bundle extras = notification.extras; title = extractStringFromExtra(extras, TITLE_KEY); } catch (Exception e) { Log.w("NotificationPlugin", "problem parsing notification extras for " + notification.tickerText); e.printStackTrace(); } } } return title; } private RepliableNotification extractRepliableNotification(StatusBarNotification statusBarNotification) { RepliableNotification repliableNotification = new RepliableNotification(); if (statusBarNotification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { try { if (statusBarNotification.getNotification().actions != null) { for (Notification.Action act : statusBarNotification.getNotification().actions) { if (act != null && act.getRemoteInputs() != null) { // Is a reply repliableNotification.remoteInputs.addAll(Arrays.asList(act.getRemoteInputs())); repliableNotification.pendingIntent = act.actionIntent; break; } } repliableNotification.packageName = statusBarNotification.getPackageName(); repliableNotification.tag = statusBarNotification.getTag();//TODO find how to pass Tag with sending PendingIntent, might fix Hangout problem } } catch (Exception e) { Log.w("NotificationPlugin", "problem extracting notification wear for " + statusBarNotification.getNotification().tickerText); e.printStackTrace(); } } } return repliableNotification; } private String getNotificationText(Notification notification) { final String TEXT_KEY = "android.text"; String text = ""; if (notification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { try { Bundle extras = notification.extras; Object extraTextExtra = extras.get(TEXT_KEY); if (extraTextExtra != null) text = extraTextExtra.toString(); } catch (Exception e) { Log.w("NotificationPlugin", "problem parsing notification extras for " + notification.tickerText); e.printStackTrace(); } } } return text; } private static String extractStringFromExtra(Bundle extras, String key) { Object extra = extras.get(key); if (extra == null) { return null; } else if (extra instanceof String) { return (String) extra; } else if (extra instanceof SpannableString) { return extra.toString(); } else { Log.e("NotificationsPlugin", "Don't know how to extract text from extra of type: " + extra.getClass().getCanonicalName()); return null; } } /** * Returns the ticker text of the notification. * If device android version is KitKat or newer, the title and text of the notification is used * instead the ticker text. */ private String getTickerText(Notification notification) { final String TITLE_KEY = "android.title"; final String TEXT_KEY = "android.text"; String ticker = ""; if (notification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { try { Bundle extras = notification.extras; String extraTitle = extractStringFromExtra(extras, TITLE_KEY); String extraText = extractStringFromExtra(extras, TEXT_KEY); if (extraTitle != null && extraText != null && !extraText.isEmpty()) { ticker = extraTitle + ": " + extraText; } else if (extraTitle != null) { ticker = extraTitle; } else if (extraText != null) { ticker = extraText; } } catch (Exception e) { Log.w("NotificationPlugin", "problem parsing notification extras for " + notification.tickerText); e.printStackTrace(); } } if (ticker.isEmpty()) { ticker = (notification.tickerText != null) ? notification.tickerText.toString() : ""; } } return ticker; } private void sendCurrentNotifications(NotificationReceiver service) { StatusBarNotification[] notifications = service.getActiveNotifications(); for (StatusBarNotification notification : notifications) { sendNotification(notification); } } @Override public boolean onPacketReceived(final NetworkPacket np) { if (np.getBoolean("request")) { if (serviceReady) { NotificationReceiver.RunCommand(context, this::sendCurrentNotifications); } } else if (np.has("cancel")) { final String dismissedId = np.getString("cancel"); currentNotifications.remove(dismissedId); NotificationReceiver.RunCommand(context, service -> cancelNotificationCompat(service, dismissedId)); } else if (np.has("requestReplyId") && np.has("message")) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { replyToNotification(np.getString("requestReplyId"), np.getString("message")); } } return true; } @Override - public AlertDialogFragment getErrorDialog() { + public AlertDialogFragment getPermissionExplanationDialog(int requestCode) { return new StartActivityAlertDialogFragment.Builder() .setTitle(R.string.pref_plugin_notifications) .setMessage(R.string.no_permissions) .setPositiveButton(R.string.open_settings) .setNegativeButton(R.string.cancel) .setIntentAction("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") .setStartForResult(true) - .setRequestCode(MainActivity.RESULT_NEEDS_RELOAD) + .setRequestCode(requestCode) .create(); } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION_REQUEST, PACKET_TYPE_NOTIFICATION_REPLY}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION}; } //For compat with API<21, because lollipop changed the way to cancel notifications private static void cancelNotificationCompat(NotificationReceiver service, String compatKey) { if (Build.VERSION.SDK_INT >= 21) { service.cancelNotification(compatKey); } else { int first = compatKey.indexOf(':'); if (first == -1) { Log.e("cancelNotificationCompa", "Not formatted like a notification key: " + compatKey); return; } int last = compatKey.lastIndexOf(':'); String packageName = compatKey.substring(0, first); String tag = compatKey.substring(first + 1, last); if (tag.length() == 0) tag = null; String idString = compatKey.substring(last + 1); int id; try { id = Integer.parseInt(idString); } catch (Exception e) { id = 0; } service.cancelNotification(packageName, tag, id); } } private static String getNotificationKeyCompat(StatusBarNotification statusBarNotification) { String result; // first check if it's one of our remoteIds String tag = statusBarNotification.getTag(); if (tag != null && tag.startsWith("kdeconnectId:")) result = Integer.toString(statusBarNotification.getId()); else if (Build.VERSION.SDK_INT >= 21) { result = statusBarNotification.getKey(); } else { String packageName = statusBarNotification.getPackageName(); int id = statusBarNotification.getId(); String safePackageName = (packageName == null) ? "" : packageName; String safeTag = (tag == null) ? "" : tag; result = safePackageName + ":" + safeTag + ":" + id; } return result; } private String getChecksum(byte[] data) { try { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(data); return bytesToHex(md.digest()); } catch (NoSuchAlgorithmException e) { Log.e("KDEConnect", "Error while generating checksum", e); } return null; } private static String bytesToHex(byte[] bytes) { char[] hexArray = "0123456789ABCDEF".toCharArray(); char[] hexChars = new char[bytes.length * 2]; for (int j = 0; j < bytes.length; j++) { int v = bytes[j] & 0xFF; hexChars[j * 2] = hexArray[v >>> 4]; hexChars[j * 2 + 1] = hexArray[v & 0x0F]; } return new String(hexChars).toLowerCase(); } @Override public int getMinSdk() { return Build.VERSION_CODES.JELLY_BEAN_MR2; } } diff --git a/src/org/kde/kdeconnect/Plugins/Plugin.java b/src/org/kde/kdeconnect/Plugins/Plugin.java index 73317b39..1ec21766 100644 --- a/src/org/kde/kdeconnect/Plugins/Plugin.java +++ b/src/org/kde/kdeconnect/Plugins/Plugin.java @@ -1,266 +1,262 @@ /* * 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; import android.app.Activity; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.os.Build; import android.widget.Button; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.UserInterface.AlertDialogFragment; import org.kde.kdeconnect.UserInterface.PermissionsAlertDialogFragment; import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; import org.kde.kdeconnect_tp.R; import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; public abstract class Plugin { protected Device device; protected Context context; protected int permissionExplanation = R.string.permission_explanation; protected int optionalPermissionExplanation = R.string.optional_permission_explanation; public final void setContext(Context context, Device device) { this.device = device; this.context = context; } /** * To receive the network package from the unpaired device, override * listensToUnpairedDevices to return true and this method. */ public boolean onUnpairedDevicePacketReceived(NetworkPacket np) { return false; } /** * Returns whether this plugin should be loaded or not, to listen to NetworkPackets * from the unpaired devices. By default, returns false. */ public boolean listensToUnpairedDevices() { return false; } /** * Return the internal plugin name, that will be used as a * unique key to distinguish it. Use the class name as key. */ public String getPluginKey() { return getPluginKey(this.getClass()); } public static String getPluginKey(Class p) { return p.getSimpleName(); } /** * Return the human-readable plugin name. This function can * access this.context to provide translated text. */ public abstract String getDisplayName(); /** * Return the human-readable description of this plugin. This * function can access this.context to provide translated text. */ public abstract String getDescription(); /** * Return the action name displayed in the main activity, that * will call startMainActivity when clicked */ public String getActionName() { return getDisplayName(); } /** * Return an icon associated to this plugin. This function can * access this.context to load the image from resources. */ public Drawable getIcon() { return null; } /** * Return true if this plugin should be enabled on new devices. * This function can access this.context and perform compatibility * checks with the Android version, but can not access this.device. */ public boolean isEnabledByDefault() { return true; } /** * Return true if this plugin needs an specific UI settings. */ public boolean hasSettings() { return false; } /** * If hasSettings returns true, this will be called when the user * wants to access this plugin's preferences. The default implementation * will return a PluginSettingsFragment with content from "yourplugin"_preferences.xml * * @return The PluginSettingsFragment used to display this plugins settings */ public PluginSettingsFragment getSettingsFragment() { return PluginSettingsFragment.newInstance(getPluginKey()); } /** * Return true if the plugin should display something in the Device main view */ public boolean hasMainActivity() { return false; } /** * Implement here what your plugin should do when clicked */ public void startMainActivity(Activity parentActivity) { } /** * Return true if the entry for this app should appear in the context menu instead of the main view */ public boolean displayInContextMenu() { return false; } /** * Initialize the listeners and structures in your plugin. * Should return true if initialization was successful. */ public boolean onCreate() { return true; } /** * Finish any ongoing operations, remove listeners... so * this object could be garbage collected. */ public void onDestroy() { } /** * Called when a plugin receives a package. By convention we return true * when we have done something in response to the package or false * otherwise, even though that value is unused as of now. */ public boolean onPacketReceived(NetworkPacket np) { return false; } /** * Should return the list of NetworkPacket types that this plugin can handle */ public abstract String[] getSupportedPacketTypes(); /** * Should return the list of NetworkPacket types that this plugin can send */ public abstract String[] getOutgoingPacketTypes(); /** * Creates a button that will be displayed in the user interface * It can open an activity or perform any other action that the * plugin would wants to expose to the user. Return null if no * button should be displayed. */ @Deprecated public Button getInterfaceButton(final Activity activity) { if (!hasMainActivity()) return null; Button b = new Button(activity); b.setText(getActionName()); b.setOnClickListener(view -> startMainActivity(activity)); return b; } protected String[] getRequiredPermissions() { return new String[0]; } protected String[] getOptionalPermissions() { return new String[0]; } //Permission from Manifest.permission.* protected boolean isPermissionGranted(String permission) { int result = ContextCompat.checkSelfPermission(context, permission); return (result == PackageManager.PERMISSION_GRANTED); } private boolean arePermissionsGranted(String[] permissions) { for (String permission : permissions) { if (!isPermissionGranted(permission)) { return false; } } return true; } private PermissionsAlertDialogFragment requestPermissionDialog(final String[] permissions, @StringRes int reason, int requestCode) { return new PermissionsAlertDialogFragment.Builder() .setTitle(getDisplayName()) .setMessage(reason) .setPositiveButton(R.string.ok) .setNegativeButton(R.string.cancel) .setPermissions(permissions) .setRequestCode(requestCode) .create(); } /** * If onCreate returns false, should create a dialog explaining * the problem (and how to fix it, if possible) to the user. */ - public AlertDialogFragment getErrorDialog() { - return null; - } - public AlertDialogFragment getPermissionExplanationDialog(int requestCode) { return requestPermissionDialog(getRequiredPermissions(), permissionExplanation, requestCode); } public AlertDialogFragment getOptionalPermissionExplanationDialog(int requestCode) { return requestPermissionDialog(getOptionalPermissions(), optionalPermissionExplanation, requestCode); } public boolean checkRequiredPermissions() { return arePermissionsGranted(getRequiredPermissions()); } public boolean checkOptionalPermissions() { return arePermissionsGranted(getOptionalPermissions()); } public int getMinSdk() { return Build.VERSION_CODES.BASE; } } diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java index 70806ca8..2d350278 100644 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java @@ -1,326 +1,327 @@ /* * Copyright 2014 Samoilenko Yuri * * 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.SftpPlugin; import android.content.Context; import android.net.Uri; import android.util.Log; import org.apache.sshd.SshServer; import org.apache.sshd.common.Session; import org.apache.sshd.common.file.FileSystemFactory; import org.apache.sshd.common.file.FileSystemView; import org.apache.sshd.common.file.nativefs.NativeFileSystemView; import org.apache.sshd.common.file.nativefs.NativeSshFile; import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider; import org.apache.sshd.common.util.SecurityUtils; import org.apache.sshd.server.PasswordAuthenticator; import org.apache.sshd.server.PublickeyAuthenticator; import org.apache.sshd.server.command.ScpCommandFactory; import org.apache.sshd.server.kex.DHG1; import org.apache.sshd.server.kex.DHG14; import org.apache.sshd.server.session.ServerSession; import org.apache.sshd.server.sftp.SftpSubsystem; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.MediaStoreHelper; import org.kde.kdeconnect.Helpers.RandomHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.RandomAccessFile; import java.net.Inet4Address; import java.net.InetAddress; import java.net.NetworkInterface; import java.net.SocketException; +import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.PrivateKey; import java.security.PublicKey; import java.security.Security; import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; class SimpleSftpServer { private static final int STARTPORT = 1739; private static final int ENDPORT = 1764; static final String USER = "kdeconnect"; private int port = -1; private boolean started = false; private final SimplePasswordAuthenticator passwordAuth = new SimplePasswordAuthenticator(); private final SimplePublicKeyAuthenticator keyAuth = new SimplePublicKeyAuthenticator(); static { Security.insertProviderAt(SslHelper.BC, 1); SecurityUtils.setRegisterBouncyCastle(false); } private final SshServer sshd = SshServer.setUpDefaultServer(); - void init(Context context, Device device) throws Exception { + void init(Context context, Device device) throws GeneralSecurityException { sshd.setKeyExchangeFactories(Arrays.asList( new DHG14.Factory(), new DHG1.Factory())); //Reuse this device keys for the ssh connection as well final KeyPair keyPair; PrivateKey privateKey = RsaHelper.getPrivateKey(context); PublicKey publicKey = RsaHelper.getPublicKey(context); keyPair = new KeyPair(publicKey, privateKey); sshd.setKeyPairProvider(new AbstractKeyPairProvider() { @Override public Iterable loadKeys() { return Collections.singletonList(keyPair); } }); sshd.setFileSystemFactory(new AndroidFileSystemFactory(context)); sshd.setCommandFactory(new ScpCommandFactory()); sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystem.Factory())); if (device.publicKey != null) { keyAuth.deviceKey = device.publicKey; } else { keyAuth.deviceKey = device.certificate.getPublicKey(); } sshd.setPublickeyAuthenticator(keyAuth); sshd.setPasswordAuthenticator(passwordAuth); } public boolean start() { if (!started) { passwordAuth.password = RandomHelper.randomString(28); port = STARTPORT; while (!started) { try { sshd.setPort(port); sshd.start(); started = true; } catch (Exception e) { e.printStackTrace(); port++; if (port >= ENDPORT) { port = -1; Log.e("SftpServer", "No more ports available"); return false; } } } } return true; } public void stop() { try { started = false; sshd.stop(true); } catch (Exception e) { e.printStackTrace(); } } String getPassword() { return passwordAuth.password; } int getPort() { return port; } String getLocalIpAddress() { String ip6 = null; try { for (Enumeration en = NetworkInterface.getNetworkInterfaces(); en.hasMoreElements(); ) { NetworkInterface intf = en.nextElement(); // Anything with rmnet is related to cellular connections or USB // tethering mechanisms. See: // // https://android.googlesource.com/kernel/msm/+/android-msm-flo-3.4-kitkat-mr1/Documentation/usb/gadget_rmnet.txt // // If we run across an interface that has this, we can safely // ignore it. In fact, it's much safer to do. If we don't, we // might get invalid IP adddresses out of it. if (intf.getDisplayName().contains("rmnet")) continue; for (Enumeration enumIpAddr = intf.getInetAddresses(); enumIpAddr.hasMoreElements(); ) { InetAddress inetAddress = enumIpAddr.nextElement(); if (!inetAddress.isLoopbackAddress()) { String address = inetAddress.getHostAddress(); if (inetAddress instanceof Inet4Address) { //Prefer IPv4 over IPv6, because sshfs doesn't seem to like IPv6 return address; } else { ip6 = address; } } } } } catch (SocketException ignored) { } return ip6; } static class AndroidFileSystemFactory implements FileSystemFactory { final private Context context; AndroidFileSystemFactory(Context context) { this.context = context; } @Override public FileSystemView createFileSystemView(final Session username) { return new AndroidFileSystemView(username.getUsername(), context); } } static class AndroidFileSystemView extends NativeFileSystemView { final private Context context; AndroidFileSystemView(final String userName, Context context) { super(userName, true); this.context = context; } // NativeFileSystemView.getFile(), NativeSshFile.getParentFile() and NativeSshFile.listSshFiles() call // createNativeSshFile to create new NativeSshFiles so override that instead of getFile() to always create a AndroidSshFile @Override public AndroidSshFile createNativeSshFile(String name, File file, String username) { return new AndroidSshFile(this, name, file, username, context); } } static class AndroidSshFile extends NativeSshFile { final private Context context; final private File file; AndroidSshFile(final AndroidFileSystemView view, String name, final File file, final String userName, Context context) { super(view, name, file, userName); this.context = context; this.file = file; } @Override public OutputStream createOutputStream(long offset) throws IOException { if (!isWritable()) { throw new IOException("No write permission : " + file.getName()); } final RandomAccessFile raf = new RandomAccessFile(file, "rw"); try { if (offset < raf.length()) { throw new IOException("Your SSHFS is bugged"); //SSHFS 3.0 and 3.2 cause data corruption, abort the transfer if this happens } raf.setLength(offset); raf.seek(offset); return new FileOutputStream(raf.getFD()) { public void close() throws IOException { super.close(); raf.close(); } }; } catch (IOException e) { raf.close(); throw e; } } @Override public boolean delete() { //Log.e("Sshd", "deleting file"); boolean ret = super.delete(); if (ret) { MediaStoreHelper.indexFile(context, Uri.fromFile(file)); } return ret; } @Override public boolean create() throws IOException { //Log.e("Sshd", "creating file"); boolean ret = super.create(); if (ret) { MediaStoreHelper.indexFile(context, Uri.fromFile(file)); } return ret; } // Based on https://github.com/wolpi/prim-ftpd/blob/master/primitiveFTPd/src/org/primftpd/filesystem/FsFile.java @Override public boolean doesExist() { boolean exists = file.exists(); if (!exists) { // file.exists() returns false when we don't have read permission // try to figure out if it really does not exist File parentFile = file.getParentFile(); File[] children = parentFile.listFiles(); if (children != null) { for (File child : children) { if (file.equals(child)) { exists = true; break; } } } } return exists; } } static class SimplePasswordAuthenticator implements PasswordAuthenticator { String password; @Override public boolean authenticate(String user, String password, ServerSession session) { return user.equals(SimpleSftpServer.USER) && password.equals(this.password); } } static class SimplePublicKeyAuthenticator implements PublickeyAuthenticator { PublicKey deviceKey; @Override public boolean authenticate(String user, PublicKey key, ServerSession session) { return deviceKey.equals(key); } } } diff --git a/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java b/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java index 6594993a..66d1adc6 100644 --- a/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java +++ b/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java @@ -1,441 +1,433 @@ /* * 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.UserInterface; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.ListView; import android.widget.ProgressBar; import android.widget.TextView; import org.kde.kdeconnect.BackgroundService; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.NetworkHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.List.CustomItem; import org.kde.kdeconnect.UserInterface.List.FailedPluginListItem; import org.kde.kdeconnect.UserInterface.List.ListAdapter; import org.kde.kdeconnect.UserInterface.List.PluginItem; import org.kde.kdeconnect.UserInterface.List.SmallEntryItem; import org.kde.kdeconnect_tp.R; import java.util.ArrayList; import java.util.Collection; import java.util.ConcurrentModificationException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; import butterknife.Unbinder; /** * Main view. Displays the current device and its plugins */ public class DeviceFragment extends Fragment { private static final String ARG_DEVICE_ID = "deviceId"; private static final String ARG_FROM_DEVICE_LIST = "fromDeviceList"; private View rootView; private String mDeviceId; private Device device; private MainActivity mActivity; private ArrayList pluginListItems; @BindView(R.id.pair_button) Button pairButton; @BindView(R.id.accept_button) Button acceptButton; @BindView(R.id.reject_button) Button rejectButton; @BindView(R.id.pair_message) TextView pairMessage; @BindView(R.id.pair_progress) ProgressBar pairProgress; @BindView(R.id.pairing_buttons) View pairingButtons; @BindView(R.id.pair_request_buttons) View pairRequestButtons; @BindView(R.id.error_message_container) View errorMessageContainer; @BindView(R.id.not_reachable_message) TextView notReachableMessage; @BindView(R.id.on_data_message) TextView onDataMessage; @BindView(R.id.buttons_list) ListView buttonsList; private Unbinder unbinder; public DeviceFragment() { } public static DeviceFragment newInstance(String deviceId, boolean fromDeviceList) { DeviceFragment frag = new DeviceFragment(); Bundle args = new Bundle(); args.putString(ARG_DEVICE_ID, deviceId); args.putBoolean(ARG_FROM_DEVICE_LIST, fromDeviceList); frag.setArguments(args); return frag; } @Override public void onAttach(Activity activity) { super.onAttach(activity); mActivity = ((MainActivity) getActivity()); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (getArguments() == null || !getArguments().containsKey(ARG_DEVICE_ID)) { throw new RuntimeException("You must instantiate a new DeviceFragment using DeviceFragment.newInstance()"); } mDeviceId = getArguments().getString(ARG_DEVICE_ID); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { rootView = inflater.inflate(R.layout.activity_device, container, false); unbinder = ButterKnife.bind(this, rootView); setHasOptionsMenu(true); //Log.e("DeviceFragment", "device: " + deviceId); BackgroundService.RunCommand(mActivity, service -> { device = service.getDevice(mDeviceId); if (device == null) { Log.e("DeviceFragment", "Trying to display a device fragment but the device is not present"); mActivity.onDeviceSelected(null); return; } mActivity.getSupportActionBar().setTitle(device.getName()); device.addPairingCallback(pairingCallback); device.addPluginsChangedListener(pluginsChangedListener); refreshUI(); }); return rootView; } String getDeviceId() { return mDeviceId; } private final Device.PluginsChangedListener pluginsChangedListener = device -> refreshUI(); @OnClick(R.id.pair_button) void pairButtonClicked(Button pairButton) { pairButton.setVisibility(View.GONE); pairMessage.setText(""); pairProgress.setVisibility(View.VISIBLE); BackgroundService.RunCommand(mActivity, service -> { device = service.getDevice(mDeviceId); if (device == null) return; device.requestPairing(); }); } @OnClick(R.id.accept_button) void acceptButtonClicked() { if (device != null) { device.acceptPairing(); pairingButtons.setVisibility(View.GONE); } } @OnClick(R.id.reject_button) void setRejectButtonClicked() { if (device != null) { //Remove listener so buttons don't show for a while before changing the view device.removePluginsChangedListener(pluginsChangedListener); device.removePairingCallback(pairingCallback); device.rejectPairing(); } mActivity.onDeviceSelected(null); } @Override public void onDestroyView() { BackgroundService.RunCommand(mActivity, service -> { Device device = service.getDevice(mDeviceId); if (device == null) return; device.removePluginsChangedListener(pluginsChangedListener); device.removePairingCallback(pairingCallback); }); unbinder.unbind(); super.onDestroyView(); } @Override public void onPrepareOptionsMenu(Menu menu) { //Log.e("DeviceFragment", "onPrepareOptionsMenu"); super.onPrepareOptionsMenu(menu); menu.clear(); if (device == null) { return; } //Plugins button list final Collection plugins = device.getLoadedPlugins().values(); for (final Plugin p : plugins) { if (!p.displayInContextMenu()) { continue; } menu.add(p.getActionName()).setOnMenuItemClickListener(item -> { p.startMainActivity(mActivity); return true; }); } menu.add(R.string.device_menu_plugins).setOnMenuItemClickListener(menuItem -> { Intent intent = new Intent(mActivity, DeviceSettingsActivity.class); intent.putExtra("deviceId", mDeviceId); startActivity(intent); return true; }); if (device.isReachable()) { menu.add(R.string.encryption_info_title).setOnMenuItemClickListener(menuItem -> { Context context = mActivity; AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle(context.getResources().getString(R.string.encryption_info_title)); builder.setPositiveButton(context.getResources().getString(R.string.ok), (dialog, id) -> dialog.dismiss()); if (device.certificate == null) { builder.setMessage(R.string.encryption_info_msg_no_ssl); } else { builder.setMessage(context.getResources().getString(R.string.my_device_fingerprint) + "\n" + SslHelper.getCertificateHash(SslHelper.certificate) + "\n\n" + context.getResources().getString(R.string.remote_device_fingerprint) + "\n" + SslHelper.getCertificateHash(device.certificate)); } builder.create().show(); return true; }); } if (device.isPaired()) { menu.add(R.string.device_menu_unpair).setOnMenuItemClickListener(menuItem -> { //Remove listener so buttons don't show for a while before changing the view device.removePluginsChangedListener(pluginsChangedListener); device.removePairingCallback(pairingCallback); device.unpair(); mActivity.onDeviceSelected(null); return true; }); } } @Override public void onResume() { super.onResume(); getView().setFocusableInTouchMode(true); getView().requestFocus(); getView().setOnKeyListener((v, keyCode, event) -> { if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { boolean fromDeviceList = getArguments().getBoolean(ARG_FROM_DEVICE_LIST, false); // Handle back button so we go to the list of devices in case we came from there if (fromDeviceList) { mActivity.onDeviceSelected(null); return true; } } return false; }); } private void refreshUI() { //Log.e("DeviceFragment", "refreshUI"); if (device == null || rootView == null) { return; } //Once in-app, there is no point in keep displaying the notification if any device.hidePairingNotification(); mActivity.runOnUiThread(new Runnable() { @Override public void run() { if (device.isPairRequestedByPeer()) { pairMessage.setText(R.string.pair_requested); pairingButtons.setVisibility(View.VISIBLE); pairProgress.setVisibility(View.GONE); pairButton.setVisibility(View.GONE); pairRequestButtons.setVisibility(View.VISIBLE); } else { boolean paired = device.isPaired(); boolean reachable = device.isReachable(); boolean onData = NetworkHelper.isOnMobileNetwork(DeviceFragment.this.getContext()); pairingButtons.setVisibility(paired ? View.GONE : View.VISIBLE); errorMessageContainer.setVisibility((paired && !reachable) ? View.VISIBLE : View.GONE); notReachableMessage.setVisibility((paired && !reachable && !onData) ? View.VISIBLE : View.GONE); onDataMessage.setVisibility((paired && !reachable && onData) ? View.VISIBLE : View.GONE); try { pluginListItems = new ArrayList<>(); if (paired && reachable) { //Plugins button list final Collection plugins = device.getLoadedPlugins().values(); for (final Plugin p : plugins) { if (!p.hasMainActivity()) continue; if (p.displayInContextMenu()) continue; pluginListItems.add(new PluginItem(p, v -> p.startMainActivity(mActivity))); } - - DeviceFragment.this.createPluginsList(device.getFailedPlugins(), R.string.plugins_failed_to_load, (plugin) -> { - AlertDialogFragment dialog = plugin.getErrorDialog(); - if (dialog != null) { - dialog.show(getChildFragmentManager(), null); - } - }); DeviceFragment.this.createPluginsList(device.getPluginsWithoutPermissions(), R.string.plugins_need_permission, (plugin) -> { - AlertDialogFragment dialog = plugin.getPermissionExplanationDialog(0); - + AlertDialogFragment dialog = plugin.getPermissionExplanationDialog(MainActivity.RESULT_NEEDS_RELOAD); if (dialog != null) { dialog.show(getChildFragmentManager(), null); } }); DeviceFragment.this.createPluginsList(device.getPluginsWithoutOptionalPermissions(), R.string.plugins_need_optional_permission, (plugin) -> { - AlertDialogFragment dialog = plugin.getOptionalPermissionExplanationDialog(0); + AlertDialogFragment dialog = plugin.getOptionalPermissionExplanationDialog(MainActivity.RESULT_NEEDS_RELOAD); if (dialog != null) { dialog.show(getChildFragmentManager(), null); } }); } ListAdapter adapter = new ListAdapter(mActivity, pluginListItems); buttonsList.setAdapter(adapter); mActivity.invalidateOptionsMenu(); } catch (IllegalStateException e) { e.printStackTrace(); //Ignore: The activity was closed while we were trying to update it } catch (ConcurrentModificationException e) { Log.e("DeviceActivity", "ConcurrentModificationException"); this.run(); //Try again } } } }); } private final Device.PairingCallback pairingCallback = new Device.PairingCallback() { @Override public void incomingRequest() { refreshUI(); } @Override public void pairingSuccessful() { refreshUI(); } @Override public void pairingFailed(final String error) { mActivity.runOnUiThread(() -> { if (rootView == null) return; pairMessage.setText(error); pairProgress.setVisibility(View.GONE); pairButton.setVisibility(View.VISIBLE); pairRequestButtons.setVisibility(View.GONE); refreshUI(); }); } @Override public void unpaired() { mActivity.runOnUiThread(() -> { if (rootView == null) return; pairMessage.setText(R.string.device_not_paired); pairProgress.setVisibility(View.GONE); pairButton.setVisibility(View.VISIBLE); pairRequestButtons.setVisibility(View.GONE); refreshUI(); }); } }; private void createPluginsList(ConcurrentHashMap plugins, int headerText, FailedPluginListItem.Action action) { if (!plugins.isEmpty()) { TextView header = new TextView(mActivity); header.setPadding( ((int) (16 * getResources().getDisplayMetrics().density)), ((int) (28 * getResources().getDisplayMetrics().density)), ((int) (16 * getResources().getDisplayMetrics().density)), ((int) (8 * getResources().getDisplayMetrics().density)) ); header.setOnClickListener(null); header.setOnLongClickListener(null); header.setText(headerText); pluginListItems.add(new CustomItem(header)); for (Map.Entry entry : plugins.entrySet()) { String pluginKey = entry.getKey(); final Plugin plugin = entry.getValue(); if (device.isPluginEnabled(pluginKey)) { if (plugin == null) { pluginListItems.add(new SmallEntryItem(pluginKey)); } else { pluginListItems.add(new FailedPluginListItem(plugin, action)); } } } } } } diff --git a/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java b/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java index df666550..5f79c743 100644 --- a/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java +++ b/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java @@ -1,134 +1,134 @@ /* * 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.UserInterface; import android.os.Bundle; import android.view.MenuItem; import org.kde.kdeconnect.BackgroundService; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect_tp.R; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; public class DeviceSettingsActivity extends AppCompatActivity implements PluginPreference.PluginPreferenceCallback { public static final String EXTRA_DEVICE_ID = "deviceId"; public static final String EXTRA_PLUGIN_KEY = "pluginKey"; //TODO: Save/restore state static private String deviceId; //Static because if we get here by using the back button in the action bar, the extra deviceId will not be set. @Override public void onCreate(Bundle savedInstanceState) { ThemeUtil.setUserPreferredTheme(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_device_settings); if (getSupportActionBar() != null) { getSupportActionBar().setDefaultDisplayHomeAsUpEnabled(true); } String pluginKey = null; if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID); if (getIntent().hasExtra(EXTRA_PLUGIN_KEY)) { pluginKey = getIntent().getStringExtra(EXTRA_PLUGIN_KEY); } } else if (deviceId == null) { throw new RuntimeException("You must start DeviceSettingActivity using an intent that has a " + EXTRA_DEVICE_ID + " extra"); } Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragmentPlaceHolder); if (fragment == null) { if (pluginKey == null) { fragment = DeviceSettingsFragment.newInstance(deviceId); } else { Device device = BackgroundService.getInstance().getDevice(deviceId); - Plugin plugin = device.getPlugin(pluginKey, true); + Plugin plugin = device.getPlugin(pluginKey); fragment = plugin.getSettingsFragment(); } getSupportFragmentManager() .beginTransaction() .add(R.id.fragmentPlaceHolder, fragment) .commit(); } } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { FragmentManager fm = getSupportFragmentManager(); if (fm.getBackStackEntryCount() > 0) { fm.popBackStack(); return true; } } return super.onOptionsItemSelected(item); } @Override protected void onStart() { super.onStart(); BackgroundService.addGuiInUseCounter(this); } @Override protected void onStop() { super.onStop(); BackgroundService.removeGuiInUseCounter(this); } @Override public void onStartPluginSettingsFragment(Plugin plugin) { setTitle(getString(R.string.plugin_settings_with_name, plugin.getDisplayName())); PluginSettingsFragment fragment = plugin.getSettingsFragment(); //TODO: Remove when NotificationFilterActivity has been turned into a PluginSettingsFragment if (fragment == null) { return; } getSupportFragmentManager() .beginTransaction() .replace(R.id.fragmentPlaceHolder, fragment) .addToBackStack(null) .commit(); } @Override public void onFinish() { finish(); } } diff --git a/src/org/kde/kdeconnect/UserInterface/MainActivity.java b/src/org/kde/kdeconnect/UserInterface/MainActivity.java index 5d900bfc..13b9c263 100644 --- a/src/org/kde/kdeconnect/UserInterface/MainActivity.java +++ b/src/org/kde/kdeconnect/UserInterface/MainActivity.java @@ -1,385 +1,390 @@ package org.kde.kdeconnect.UserInterface; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Bundle; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.SubMenu; import android.view.View; import android.widget.TextView; import com.google.android.material.navigation.NavigationView; import org.kde.kdeconnect.BackgroundService; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.DeviceHelper; import org.kde.kdeconnect_tp.R; import java.util.Collection; import java.util.HashMap; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import butterknife.BindView; import butterknife.ButterKnife; public class MainActivity extends AppCompatActivity implements SharedPreferences.OnSharedPreferenceChangeListener { private static final int MENU_ENTRY_ADD_DEVICE = 1; //0 means no-selection private static final int MENU_ENTRY_SETTINGS = 2; private static final int MENU_ENTRY_DEVICE_FIRST_ID = 1000; //All subsequent ids are devices in the menu private static final int MENU_ENTRY_DEVICE_UNKNOWN = 9999; //It's still a device, but we don't know which one yet private static final String STATE_SELECTED_MENU_ENTRY = "selected_entry"; //Saved only in onSaveInstanceState private static final String STATE_SELECTED_DEVICE = "selected_device"; //Saved persistently in preferences public static final int RESULT_NEEDS_RELOAD = Activity.RESULT_FIRST_USER; public static final String PAIR_REQUEST_STATUS = "pair_req_status"; public static final String PAIRING_ACCEPTED = "accepted"; public static final String PAIRING_REJECTED = "rejected"; public static final String PAIRING_PENDING = "pending"; public static final String EXTRA_DEVICE_ID = "deviceId"; @BindView(R.id.navigation_drawer) NavigationView mNavigationView; @BindView(R.id.drawer_layout) DrawerLayout mDrawerLayout; @BindView(R.id.toolbar) Toolbar mToolbar; TextView mNavViewDeviceName; private String mCurrentDevice; private int mCurrentMenuEntry; private SharedPreferences preferences; private final HashMap mMapMenuToDeviceId = new HashMap<>(); @Override protected void onCreate(Bundle savedInstanceState) { // We need to set the theme before the call to 'super.onCreate' below ThemeUtil.setUserPreferredTheme(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ButterKnife.bind(this); View mDrawerHeader = mNavigationView.getHeaderView(0); mNavViewDeviceName = mDrawerHeader.findViewById(R.id.device_name); setSupportActionBar(mToolbar); ActionBar actionBar = getSupportActionBar(); ActionBarDrawerToggle mDrawerToggle = new ActionBarDrawerToggle(this, /* host Activity */ mDrawerLayout, /* DrawerLayout object */ R.string.open, /* "open drawer" description */ R.string.close /* "close drawer" description */ ); mDrawerLayout.addDrawerListener(mDrawerToggle); mDrawerLayout.setDrawerShadow(R.drawable.drawer_shadow, GravityCompat.START); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(true); } mDrawerToggle.setDrawerIndicatorEnabled(true); mDrawerToggle.syncState(); String deviceName = DeviceHelper.getDeviceName(this); mNavViewDeviceName.setText(deviceName); preferences = getSharedPreferences("stored_menu_selection", Context.MODE_PRIVATE); PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this); mNavigationView.setNavigationItemSelectedListener(menuItem -> { mCurrentMenuEntry = menuItem.getItemId(); switch (mCurrentMenuEntry) { case MENU_ENTRY_ADD_DEVICE: mCurrentDevice = null; preferences.edit().putString(STATE_SELECTED_DEVICE, null).apply(); setContentFragment(new PairingFragment()); break; case MENU_ENTRY_SETTINGS: mCurrentDevice = null; preferences.edit().putString(STATE_SELECTED_DEVICE, null).apply(); setContentFragment(new SettingsFragment()); break; default: String deviceId = mMapMenuToDeviceId.get(menuItem); onDeviceSelected(deviceId); break; } mDrawerLayout.closeDrawer(mNavigationView); return true; }); // Decide which menu entry should be selected at start String savedDevice; int savedMenuEntry; if (getIntent().hasExtra("forceOverview")) { Log.i("MainActivity", "Requested to start main overview"); savedDevice = null; savedMenuEntry = MENU_ENTRY_ADD_DEVICE; } else if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { Log.i("MainActivity", "Loading selected device from parameter"); savedDevice = getIntent().getStringExtra(EXTRA_DEVICE_ID); savedMenuEntry = MENU_ENTRY_DEVICE_UNKNOWN; // If pairStatus is not empty, then the user has accepted/reject the pairing from the notification String pairStatus = getIntent().getStringExtra(PAIR_REQUEST_STATUS); if (pairStatus != null) { Log.i("MainActivity", "pair status is " + pairStatus); savedDevice = onPairResultFromNotification(savedDevice, pairStatus); if (savedDevice == null) { savedMenuEntry = MENU_ENTRY_ADD_DEVICE; } } } else if (savedInstanceState != null) { Log.i("MainActivity", "Loading selected device from saved activity state"); savedDevice = savedInstanceState.getString(STATE_SELECTED_DEVICE); savedMenuEntry = savedInstanceState.getInt(STATE_SELECTED_MENU_ENTRY, MENU_ENTRY_ADD_DEVICE); } else { Log.i("MainActivity", "Loading selected device from persistent storage"); savedDevice = preferences.getString(STATE_SELECTED_DEVICE, null); savedMenuEntry = (savedDevice != null)? MENU_ENTRY_DEVICE_UNKNOWN : MENU_ENTRY_ADD_DEVICE; } mCurrentMenuEntry = savedMenuEntry; mCurrentDevice = savedDevice; mNavigationView.setCheckedItem(savedMenuEntry); //FragmentManager will restore whatever fragment was there if (savedInstanceState != null) { Fragment frag = getSupportFragmentManager().findFragmentById(R.id.container); if (!(frag instanceof DeviceFragment) || ((DeviceFragment)frag).getDeviceId().equals(savedDevice)) { return; } } // Activate the chosen fragment and select the entry in the menu if (savedMenuEntry >= MENU_ENTRY_DEVICE_FIRST_ID && savedDevice != null) { onDeviceSelected(savedDevice); } else { if (mCurrentMenuEntry == MENU_ENTRY_SETTINGS) { setContentFragment(new SettingsFragment()); } else { setContentFragment(new PairingFragment()); } } } @Override protected void onDestroy() { super.onDestroy(); PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(this); } private String onPairResultFromNotification(String deviceId, String pairStatus) { assert(deviceId != null); if (!pairStatus.equals(PAIRING_PENDING)) { BackgroundService.RunCommand(this, service -> { Device device = service.getDevice(deviceId); if (device == null) { Log.w("rejectPairing", "Device no longer exists: " + deviceId); return; } if (pairStatus.equals(PAIRING_ACCEPTED)) { device.acceptPairing(); } else if (pairStatus.equals(PAIRING_REJECTED)) { device.rejectPairing(); } }); } if (pairStatus.equals(PAIRING_ACCEPTED) || pairStatus.equals(PAIRING_PENDING)) { return deviceId; } else { return null; } } private int deviceIdToMenuEntryId(String deviceId) { for (HashMap.Entry entry : mMapMenuToDeviceId.entrySet()) { if (TextUtils.equals(entry.getValue(), deviceId)) { //null-safe return entry.getKey().getItemId(); } } return MENU_ENTRY_DEVICE_UNKNOWN; } @Override public void onBackPressed() { if (mDrawerLayout.isDrawerOpen(mNavigationView)) { mDrawerLayout.closeDrawer(mNavigationView); } else { super.onBackPressed(); } } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { mDrawerLayout.openDrawer(mNavigationView); return true; } else { return super.onOptionsItemSelected(item); } } private void updateDeviceList() { BackgroundService.RunCommand(MainActivity.this, service -> { Menu menu = mNavigationView.getMenu(); menu.clear(); mMapMenuToDeviceId.clear(); SubMenu devicesMenu = menu.addSubMenu(R.string.devices); int id = MENU_ENTRY_DEVICE_FIRST_ID; Collection devices = service.getDevices().values(); for (Device device : devices) { if (device.isReachable() && device.isPaired()) { MenuItem item = devicesMenu.add(Menu.FIRST, id++, 1, device.getName()); item.setIcon(device.getIcon()); item.setCheckable(true); mMapMenuToDeviceId.put(item, device.getDeviceId()); } } MenuItem addDeviceItem = devicesMenu.add(Menu.FIRST, MENU_ENTRY_ADD_DEVICE, 1000, R.string.pair_new_device); addDeviceItem.setIcon(R.drawable.ic_action_content_add_circle_outline); addDeviceItem.setCheckable(true); MenuItem settingsItem = menu.add(Menu.FIRST, MENU_ENTRY_SETTINGS, 1000, R.string.settings); settingsItem.setIcon(R.drawable.ic_action_settings); settingsItem.setCheckable(true); //Ids might have changed if (mCurrentMenuEntry >= MENU_ENTRY_DEVICE_FIRST_ID) { mCurrentMenuEntry = deviceIdToMenuEntryId(mCurrentDevice); } mNavigationView.setCheckedItem(mCurrentMenuEntry); }); } @Override protected void onStart() { super.onStart(); BackgroundService.addGuiInUseCounter(this, true); BackgroundService.RunCommand(this, service -> service.addDeviceListChangedCallback("MainActivity", this::updateDeviceList)); updateDeviceList(); } @Override protected void onStop() { BackgroundService.removeGuiInUseCounter(this); BackgroundService.RunCommand(this, service -> service.removeDeviceListChangedCallback("MainActivity")); super.onStop(); } private static void uncheckAllMenuItems(Menu menu) { int size = menu.size(); for (int i = 0; i < size; i++) { MenuItem item = menu.getItem(i); if(item.hasSubMenu()) { uncheckAllMenuItems(item.getSubMenu()); } else { item.setChecked(false); } } } public void onDeviceSelected(String deviceId, boolean fromDeviceList) { mCurrentDevice = deviceId; preferences.edit().putString(STATE_SELECTED_DEVICE, deviceId).apply(); if (mCurrentDevice != null) { mCurrentMenuEntry = deviceIdToMenuEntryId(deviceId); if (mCurrentMenuEntry == MENU_ENTRY_DEVICE_UNKNOWN) { uncheckAllMenuItems(mNavigationView.getMenu()); } else { mNavigationView.setCheckedItem(mCurrentMenuEntry); } setContentFragment(DeviceFragment.newInstance(deviceId, fromDeviceList)); } else { mCurrentMenuEntry = MENU_ENTRY_ADD_DEVICE; mNavigationView.setCheckedItem(mCurrentMenuEntry); setContentFragment(new PairingFragment()); } } private void setContentFragment(Fragment fragment) { getSupportFragmentManager() .beginTransaction() .replace(R.id.container, fragment) .commit(); } public void onDeviceSelected(String deviceId) { onDeviceSelected(deviceId, false); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString(STATE_SELECTED_DEVICE, mCurrentDevice); outState.putInt(STATE_SELECTED_MENU_ENTRY, mCurrentMenuEntry); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { switch (requestCode) { case RESULT_NEEDS_RELOAD: BackgroundService.RunCommand(this, service -> { Device device = service.getDevice(mCurrentDevice); device.reloadPluginsFromSettings(); }); break; default: super.onActivityResult(requestCode, resultCode, data); } } @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + boolean grantedPermission = false; for (int result : grantResults) { if (result == PackageManager.PERMISSION_GRANTED) { - //New permission granted, reload plugins - BackgroundService.RunCommand(this, service -> { - Device device = service.getDevice(mCurrentDevice); - device.reloadPluginsFromSettings(); - }); + grantedPermission = true; + break; } } + if (grantedPermission) { + //New permission granted, reload plugins + BackgroundService.RunCommand(this, service -> { + Device device = service.getDevice(mCurrentDevice); + device.reloadPluginsFromSettings(); + }); + } } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { switch (key) { case DeviceHelper.KEY_DEVICE_NAME_PREFERENCE: mNavViewDeviceName.setText(DeviceHelper.getDeviceName(this)); BackgroundService.RunCommand(this, BackgroundService::onNetworkChange); break; default: break; } } } diff --git a/src/org/kde/kdeconnect/UserInterface/PluginPreference.java b/src/org/kde/kdeconnect/UserInterface/PluginPreference.java index 3c1da8fa..691da956 100644 --- a/src/org/kde/kdeconnect/UserInterface/PluginPreference.java +++ b/src/org/kde/kdeconnect/UserInterface/PluginPreference.java @@ -1,76 +1,76 @@ package org.kde.kdeconnect.UserInterface; import android.content.Context; import android.view.View; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect_tp.R; import androidx.annotation.NonNull; import androidx.preference.CheckBoxPreference; import androidx.preference.PreferenceViewHolder; class PluginPreference extends CheckBoxPreference { private final Device device; private final String pluginKey; private final View.OnClickListener listener; public PluginPreference(@NonNull final Context context, @NonNull final String pluginKey, @NonNull final Device device, @NonNull PluginPreferenceCallback callback) { super(context); setLayoutResource(R.layout.preference_with_button/*R.layout.preference_with_button_androidx*/); this.device = device; this.pluginKey = pluginKey; PluginFactory.PluginInfo info = PluginFactory.getPluginInfo(pluginKey); setTitle(info.getDisplayName()); setSummary(info.getDescription()); setIcon(android.R.color.transparent); setChecked(device.isPluginEnabled(pluginKey)); - Plugin plugin = device.getPlugin(pluginKey, true); + Plugin plugin = device.getPlugin(pluginKey); if (info.hasSettings() && plugin != null) { this.listener = v -> { - Plugin plugin1 = device.getPlugin(pluginKey, true); + Plugin plugin1 = device.getPlugin(pluginKey); if (plugin1 != null) { callback.onStartPluginSettingsFragment(plugin1); } else { //Could happen if the device is not connected anymore callback.onFinish(); } }; } else { this.listener = null; } } @Override public void onBindViewHolder(PreferenceViewHolder holder) { super.onBindViewHolder(holder); final View button = holder.findViewById(R.id.settingsButton); if (listener == null) { button.setVisibility(View.GONE); } else { button.setEnabled(isChecked()); button.setVisibility(View.VISIBLE); button.setOnClickListener(listener); } holder.itemView.setOnClickListener(v -> { boolean newState = !device.isPluginEnabled(pluginKey); setChecked(newState); //It actually works on API<14 button.setEnabled(newState); device.setPluginEnabled(pluginKey, newState); }); } interface PluginPreferenceCallback { void onStartPluginSettingsFragment(Plugin plugin); void onFinish(); } }