diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 29d3aff0..32133867 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,314 +1,309 @@ - - \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 9acc87e1..39ec52ac 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1,370 +1,368 @@ KDE Connect Not connected to any device Connected to: %s - Send Clipboard 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 - Clipboard Sent Remote input Use your phone or tablet as a touchpad and keyboard Slideshow 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 No devices OK 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 To receive keypresses you need to activate the KDE Connect Remote Keyboard 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 Plugin settings Unpair Pair new device Unknown device Device not reachable Device already paired Could not send package Timed out Canceled by user Canceled by other peer 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 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 %1$d file to %2$s Sending %1$d files to %2$s File: %1$s (File %2$d of %3$d) : %1$s 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 Sent file to %1$s Sent %2$d files to %1$s" Failed sending file to %1$s Failed sending %2$d of %3$d files to %1$s Tap to open \'%1s\' Cannot create file %s Tap to answer Send Right Click Send Middle Click Show Keyboard Device not paired Request pairing Accept Reject Settings Play Pause Previous Rewind Fast-forward Next Volume 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 a newer protocol version %s settings Invalid device name Received text, saved to clipboard Custom device list Add devices by IP Custom device deleted If your device is not automatically detected you can add its IP address or hostname by clicking on the Floating Action Button Add a device Undo 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. SD card %d SD card (read only) Camera pictures Add device Hostname or IP address Detected SD cards Edit SD card Configured storage locations Add storage location Edit storage location Add camera folder shortcut Add a shortcut to the camera folder Do not add a shortcut to the camera folder key_sftp_preference_category key_sftp_add_storage key_sftp_add_camera_shotcut key_sftp_storage_info%d" key_sftp_storage_info_list Storage location This location has already been configured click to select Display name This display name is already used Display name cannot be empty Delete No SD card detected No storage locations configured To access files remotely you have to configure storage locations No players found Send files KDE Connect Devices Other devices running KDE Connect in your same network should appear here. Rename device Rename Refresh This paired device is not reachable. Make sure it is connected to your same network. You\'re not connected to a Wi-Fi network, so you may not be able to see any devices. Click here to enable Wi-Fi. Not on a trusted network: autodiscovery is disabled. There are no file browsers installed. Send SMS Send text messages from your desktop Find my phone Find my tablet Find my TV Rings this device so you can find it Found it Open Close 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 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 on the desktop you need to give permission to phone call logs and phone state 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 and use the volume keys to go to the previous/next slide 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 phone\'s media players from another device Other notifications Persistent indicator Media control File transfer High priority 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 Block contents of notifications Block images in notifications Notifications from other devices Launch camera Launch the camera app to ease taking and transferring pictures findmyphone_ringtone No suitable app found to open this file KDE Connect Remote Keyboard Pointer Trusted networks Restrict autodiscovery to known networks Add %1s You haven\'t added any trusted network yet Allow all Permission required Android requires the Location permission to identify your WiFi network Android 10 has removed clipboard access to all apps. This plugin will be disabled. Continue playing here Can\'t open URL to continue playing Home Up Left Select Right Down Bigscreen remote Use your device as a remote for Plasma Bigscreen diff --git a/res/values/styles.xml b/res/values/styles.xml index b5585697..7faa68e4 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -1,60 +1,52 @@ #F67400 #BD5900 #4ebffa #EEEEEE - diff --git a/src/org/kde/kdeconnect/BackgroundService.java b/src/org/kde/kdeconnect/BackgroundService.java index 8fe6b791..e878ff2c 100644 --- a/src/org/kde/kdeconnect/BackgroundService.java +++ b/src/org/kde/kdeconnect/BackgroundService.java @@ -1,484 +1,473 @@ /* * 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.app.Service; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.net.ConnectivityManager; import android.os.Binder; import android.os.Build; import android.os.IBinder; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; import androidx.core.app.NotificationCompat; import org.kde.kdeconnect.Backends.BaseLink; import org.kde.kdeconnect.Backends.BaseLinkProvider; import org.kde.kdeconnect.Backends.LanBackend.LanLinkProvider; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; -import org.kde.kdeconnect.Plugins.ClibpoardPlugin.ClipboardFloatingActivity; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandActivity; import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandPlugin; import org.kde.kdeconnect.Plugins.SharePlugin.SendFileActivity; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect_tp.R; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; //import org.kde.kdeconnect.Backends.BluetoothBackend.BluetoothLinkProvider; public class BackgroundService extends Service { private static final int FOREGROUND_NOTIFICATION_ID = 1; private static BackgroundService instance; public interface DeviceListChangedCallback { void onDeviceListChanged(); } public interface PluginCallback { void run(T plugin); } private final ConcurrentHashMap deviceListChangedCallbacks = new ConcurrentHashMap<>(); private final ArrayList linkProviders = new ArrayList<>(); private final ConcurrentHashMap devices = new ConcurrentHashMap<>(); private final HashSet discoveryModeAcquisitions = new HashSet<>(); public static BackgroundService getInstance() { return instance; } private boolean acquireDiscoveryMode(Object key) { boolean wasEmpty = discoveryModeAcquisitions.isEmpty(); discoveryModeAcquisitions.add(key); if (wasEmpty) { onNetworkChange(); } //Log.e("acquireDiscoveryMode",key.getClass().getName() +" ["+discoveryModeAcquisitions.size()+"]"); return wasEmpty; } private void releaseDiscoveryMode(Object key) { boolean removed = discoveryModeAcquisitions.remove(key); //Log.e("releaseDiscoveryMode",key.getClass().getName() +" ["+discoveryModeAcquisitions.size()+"]"); if (removed && discoveryModeAcquisitions.isEmpty()) { cleanDevices(); } } private boolean isInDiscoveryMode() { //return !discoveryModeAcquisitions.isEmpty(); return true; // Keep it always on for now } private final Device.PairingCallback devicePairingCallback = new Device.PairingCallback() { @Override public void incomingRequest() { onDeviceListChanged(); } @Override public void pairingSuccessful() { onDeviceListChanged(); } @Override public void pairingFailed(String error) { onDeviceListChanged(); } @Override public void unpaired() { onDeviceListChanged(); } }; public void onDeviceListChanged() { for (DeviceListChangedCallback callback : deviceListChangedCallbacks.values()) { callback.onDeviceListChanged(); } if (NotificationHelper.isPersistentNotificationEnabled(this)) { //Update the foreground notification with the currently connected device list NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.notify(FOREGROUND_NOTIFICATION_ID, createForegroundNotification()); } } private void loadRememberedDevicesFromSettings() { //Log.e("BackgroundService", "Loading remembered trusted devices"); SharedPreferences preferences = getSharedPreferences("trusted_devices", Context.MODE_PRIVATE); Set trustedDevices = preferences.getAll().keySet(); for (String deviceId : trustedDevices) { //Log.e("BackgroundService", "Loading device "+deviceId); if (preferences.getBoolean(deviceId, false)) { Device device = new Device(this, deviceId); devices.put(deviceId, device); device.addPairingCallback(devicePairingCallback); } } } private void registerLinkProviders() { linkProviders.add(new LanLinkProvider(this)); // linkProviders.add(new LoopbackLinkProvider(this)); // linkProviders.add(new BluetoothLinkProvider(this)); } public ArrayList getLinkProviders() { return linkProviders; } public Device getDevice(String id) { return devices.get(id); } private void cleanDevices() { new Thread(() -> { for (Device d : devices.values()) { if (!d.isPaired() && !d.isPairRequested() && !d.isPairRequestedByPeer() && !d.deviceShouldBeKeptAlive()) { d.disconnect(); } } }).start(); } private final BaseLinkProvider.ConnectionReceiver deviceListener = new BaseLinkProvider.ConnectionReceiver() { @Override public void onConnectionReceived(final NetworkPacket identityPacket, final BaseLink link) { String deviceId = identityPacket.getString("deviceId"); Device device = devices.get(deviceId); if (device != null) { Log.i("KDE/BackgroundService", "addLink, known device: " + deviceId); device.addLink(identityPacket, link); } else { Log.i("KDE/BackgroundService", "addLink,unknown device: " + deviceId); device = new Device(BackgroundService.this, identityPacket, link); if (device.isPaired() || device.isPairRequested() || device.isPairRequestedByPeer() || link.linkShouldBeKeptAlive() || isInDiscoveryMode()) { devices.put(deviceId, device); device.addPairingCallback(devicePairingCallback); } else { device.disconnect(); } } onDeviceListChanged(); } @Override public void onConnectionLost(BaseLink link) { Device d = devices.get(link.getDeviceId()); Log.i("KDE/onConnectionLost", "removeLink, deviceId: " + link.getDeviceId()); if (d != null) { d.removeLink(link); if (!d.isReachable() && !d.isPaired()) { //Log.e("onConnectionLost","Removing connection device because it was not paired"); devices.remove(link.getDeviceId()); d.removePairingCallback(devicePairingCallback); } } else { //Log.d("KDE/onConnectionLost","Removing connection to unknown device"); } onDeviceListChanged(); } }; public ConcurrentHashMap getDevices() { return devices; } public void onNetworkChange() { for (BaseLinkProvider a : linkProviders) { a.onNetworkChange(); } } public void addConnectionListener(BaseLinkProvider.ConnectionReceiver cr) { for (BaseLinkProvider a : linkProviders) { a.addConnectionReceiver(cr); } } public void removeConnectionListener(BaseLinkProvider.ConnectionReceiver cr) { for (BaseLinkProvider a : linkProviders) { a.removeConnectionReceiver(cr); } } public void addDeviceListChangedCallback(String key, DeviceListChangedCallback callback) { deviceListChangedCallbacks.put(key, callback); } public void removeDeviceListChangedCallback(String key) { deviceListChangedCallbacks.remove(key); } //This will called only once, even if we launch the service intent several times @Override public void onCreate() { super.onCreate(); instance = this; // Register screen on listener IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_ON); // See: https://developer.android.com/reference/android/net/ConnectivityManager.html#CONNECTIVITY_ACTION if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); } registerReceiver(new KdeConnectBroadcastReceiver(), filter); Log.i("KDE/BackgroundService", "Service not started yet, initializing..."); PluginFactory.initPluginInfo(getBaseContext()); initializeSecurityParameters(); NotificationHelper.initializeChannels(this); loadRememberedDevicesFromSettings(); migratePluginSettings(); registerLinkProviders(); //Link Providers need to be already registered addConnectionListener(deviceListener); for (BaseLinkProvider a : linkProviders) { a.onStart(); } } private void migratePluginSettings() { SharedPreferences globalPrefs = PreferenceManager.getDefaultSharedPreferences(this); for (String pluginKey : PluginFactory.getAvailablePlugins()) { if (PluginFactory.getPluginInfo(pluginKey).supportsDeviceSpecificSettings()) { Iterator it = devices.values().iterator(); while (it.hasNext()) { Device device = it.next(); Plugin plugin = PluginFactory.instantiatePluginForDevice(getBaseContext(), pluginKey, device); if (plugin == null) { continue; } plugin.copyGlobalToDeviceSpecificSettings(globalPrefs); if (!it.hasNext()) { plugin.removeSettings(globalPrefs); } } } } } public void changePersistentNotificationVisibility(boolean visible) { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (visible) { nm.notify(FOREGROUND_NOTIFICATION_ID, createForegroundNotification()); } else { stopForeground(true); Start(this); } } private Notification createForegroundNotification() { //Why is this needed: https://developer.android.com/guide/components/services#Foreground Intent intent = new Intent(this, MainActivity.class); PendingIntent pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.Builder notification = new NotificationCompat.Builder(this, NotificationHelper.Channels.PERSISTENT); notification .setSmallIcon(R.drawable.ic_notification) .setOngoing(true) .setContentIntent(pi) .setPriority(NotificationCompat.PRIORITY_MIN) //MIN so it's not shown in the status bar before Oreo, on Oreo it will be bumped to LOW .setShowWhen(false) .setAutoCancel(false); notification.setGroup("BackgroundService"); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { //Pre-oreo, the notification will have an empty title line without this notification.setContentTitle(getString(R.string.kde_connect)); } ArrayList connectedDevices = new ArrayList<>(); - ArrayList connectedDeviceIds = new ArrayList<>(); + ArrayList deviceIds = new ArrayList<>(); for (Device device : getDevices().values()) { if (device.isReachable() && device.isPaired()) { - connectedDeviceIds.add(device.getDeviceId()); + deviceIds.add(device.getDeviceId()); connectedDevices.add(device.getName()); } } if (connectedDevices.isEmpty()) { notification.setContentText(getString(R.string.foreground_notification_no_devices)); } else { notification.setContentText(getString(R.string.foreground_notification_devices, TextUtils.join(", ", connectedDevices))); - - // Adding an action button to send clipboard manually in Android 10 and later. - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - Intent sendClipboard = new Intent(this, ClipboardFloatingActivity.class); - sendClipboard.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); - sendClipboard.putExtra("connectedDeviceIds", connectedDeviceIds); - PendingIntent sendPendingClipboard = PendingIntent.getActivity(this, 3, sendClipboard, PendingIntent.FLAG_UPDATE_CURRENT); - notification.addAction(0, getString(R.string.foreground_notification_send_clipboard), sendPendingClipboard); - } - - if (connectedDeviceIds.size() == 1) { + if (deviceIds.size() == 1) { // Adding two action buttons only when there is a single device connected. // Setting up Send File Intent. Intent sendFile = new Intent(this, SendFileActivity.class); - sendFile.putExtra("deviceId", connectedDeviceIds.get(0)); + sendFile.putExtra("deviceId", deviceIds.get(0)); PendingIntent sendPendingFile = PendingIntent.getActivity(this, 1, sendFile, PendingIntent.FLAG_UPDATE_CURRENT); notification.addAction(0, getString(R.string.send_files), sendPendingFile); // Checking if there are registered commands and adding the button. - Device device = getDevice(connectedDeviceIds.get(0)); + Device device = getDevice(deviceIds.get(0)); RunCommandPlugin plugin = (RunCommandPlugin) device.getPlugin("RunCommandPlugin"); if (plugin != null && !plugin.getCommandList().isEmpty()) { Intent runCommand = new Intent(this, RunCommandActivity.class); - runCommand.putExtra("deviceId", connectedDeviceIds.get(0)); + runCommand.putExtra("deviceId", deviceIds.get(0)); PendingIntent runPendingCommand = PendingIntent.getActivity(this, 2, runCommand, PendingIntent.FLAG_UPDATE_CURRENT); notification.addAction(0, getString(R.string.pref_plugin_runcommand), runPendingCommand); } } } return notification.build(); } private void initializeSecurityParameters() { RsaHelper.initialiseRsaKeys(this); SslHelper.initialiseCertificate(this); } @Override public void onDestroy() { stopForeground(true); for (BaseLinkProvider a : linkProviders) { a.onStop(); } super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return new Binder(); } //To use the service from the gui public interface InstanceCallback { void onServiceStart(BackgroundService service); } private final static ArrayList callbacks = new ArrayList<>(); private final static Lock mutex = new ReentrantLock(true); @Override public int onStartCommand(Intent intent, int flags, int startId) { //This will be called for each intent launch, even if the service is already started and it is reused mutex.lock(); try { for (InstanceCallback c : callbacks) { c.onServiceStart(this); } callbacks.clear(); } finally { mutex.unlock(); } if (NotificationHelper.isPersistentNotificationEnabled(this)) { startForeground(FOREGROUND_NOTIFICATION_ID, createForegroundNotification()); } return Service.START_STICKY; } private static void Start(Context c) { RunCommand(c, null); } public static void RunCommand(final Context c, final InstanceCallback callback) { new Thread(() -> { if (callback != null) { mutex.lock(); try { callbacks.add(callback); } finally { mutex.unlock(); } } Intent serviceIntent = new Intent(c, BackgroundService.class); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { c.startForegroundService(serviceIntent); } else { c.startService(serviceIntent); } }).start(); } public static void RunWithPlugin(final Context c, final String deviceId, final Class pluginClass, final PluginCallback cb) { RunCommand(c, service -> { Device device = service.getDevice(deviceId); if (device == null) { Log.e("BackgroundService", "Device " + deviceId + " not found"); return; } final T plugin = device.getPlugin(pluginClass); if (plugin == null) { Log.e("BackgroundService", "Device " + device.getName() + " does not have plugin " + pluginClass.getName()); return; } cb.run(plugin); }); } } diff --git a/src/org/kde/kdeconnect/Plugins/ClibpoardPlugin/ClipboardFloatingActivity.java b/src/org/kde/kdeconnect/Plugins/ClibpoardPlugin/ClipboardFloatingActivity.java deleted file mode 100644 index 57e7abcf..00000000 --- a/src/org/kde/kdeconnect/Plugins/ClibpoardPlugin/ClipboardFloatingActivity.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2020 Anjani Kumar - * - * 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.ClibpoardPlugin; - -import androidx.appcompat.app.AppCompatActivity; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.os.Bundle; -import android.view.WindowManager; -import android.widget.Toast; - -import org.kde.kdeconnect.BackgroundService; -import org.kde.kdeconnect.Device; -import org.kde.kdeconnect_tp.R; - -import java.util.ArrayList; - -/* - An activity to access the clipboard on Android 10 and later by raising over other apps. - This is invisible and doesn't require any interaction from the user. - This should be called when a change in clipboard is detected. This can be done by manually - when user wants to send the clipboard or by reading system log files which requires a special - privileged permission android.permission.READ_LOGS. - https://developer.android.com/reference/android/Manifest.permission#READ_LOGS - This permission can be gained by only from the adb by the user. - https://www.reddit.com/r/AndroidBusters/comments/fh60lt/how_to_solve_a_problem_with_the_clipboard_on/ - - Currently this activity is bering triggered from a button in Foreground Notification. -* */ -public class ClipboardFloatingActivity extends AppCompatActivity { - - private ArrayList connectedDevices = new ArrayList<>(); - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - if (hasFocus) { - // We are now sure that clipboard can be accessed from here. - ClipboardManager clipboardManager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - ClipData.Item item; - if (clipboardManager.hasPrimaryClip()) { - item = clipboardManager.getPrimaryClip().getItemAt(0); - String content = item.coerceToText(this).toString(); - for (Device device : connectedDevices) { - ClipboardPlugin clipboardPlugin = (ClipboardPlugin) device.getPlugin("ClipboardPlugin"); - if (clipboardPlugin != null) { - clipboardPlugin.propagateClipboard(content); - } - } - Toast.makeText(this, R.string.pref_plugin_clipboard_sent, Toast.LENGTH_SHORT).show(); - } - finish(); - } - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - WindowManager.LayoutParams wlp = getWindow().getAttributes(); - wlp.dimAmount = 0; - wlp.flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS | - WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; - - getWindow().setAttributes(wlp); - ArrayList connectedDeviceIds = getIntent().getStringArrayListExtra("connectedDeviceIds"); - if (connectedDeviceIds != null) { - for (String deviceId : connectedDeviceIds) { - connectedDevices.add(BackgroundService.getInstance().getDevice(deviceId)); - } - } - } -} - diff --git a/src/org/kde/kdeconnect/Plugins/ClibpoardPlugin/ClipboardPlugin.java b/src/org/kde/kdeconnect/Plugins/ClibpoardPlugin/ClipboardPlugin.java index 54cae9ef..a0de5e03 100644 --- a/src/org/kde/kdeconnect/Plugins/ClibpoardPlugin/ClipboardPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/ClibpoardPlugin/ClipboardPlugin.java @@ -1,129 +1,152 @@ /* * 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.ClibpoardPlugin; +import android.os.Build; +import androidx.fragment.app.DialogFragment; +import androidx.preference.PreferenceManager; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; +import org.kde.kdeconnect.UserInterface.NoticeAlertDialogFragment; import org.kde.kdeconnect_tp.R; @PluginFactory.LoadablePlugin public class ClipboardPlugin extends Plugin { /** * Packet containing just clipboard contents, sent when a device updates its clipboard. *

* The body should look like so: * { * "content": "password" * } */ private final static String PACKET_TYPE_CLIPBOARD = "kdeconnect.clipboard"; /** * Packet containing clipboard contents and a timestamp that the contents were last updated, sent * on first connection *

* The timestamp is milliseconds since epoch. It can be 0, which indicates that the clipboard * update time is currently unknown. *

* The body should look like so: * { * "timestamp": 542904563213, * "content": "password" * } */ private final static String PACKET_TYPE_CLIPBOARD_CONNECT = "kdeconnect.clipboard.connect"; + private final static String ANDROID_10_INCOMPAT_DIALOG_SHOWN_PREFERENCE = "android10IncompatDialogShown"; + @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_clipboard); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_clipboard_desc); } @Override public boolean onPacketReceived(NetworkPacket np) { String content = np.getString("content"); switch (np.getType()) { case (PACKET_TYPE_CLIPBOARD): ClipboardListener.instance(context).setText(content); return true; case(PACKET_TYPE_CLIPBOARD_CONNECT): long packetTime = np.getLong("timestamp"); // If the packetTime is 0, it means the timestamp is unknown (so do nothing). if (packetTime == 0 || packetTime < ClipboardListener.instance(context).getUpdateTimestamp()) { return false; } ClipboardListener.instance(context).setText(content); return true; } throw new UnsupportedOperationException("Unknown packet type: " + np.getType()); } private final ClipboardListener.ClipboardObserver observer = this::propagateClipboard; - void propagateClipboard(String content) { + private void propagateClipboard(String content) { NetworkPacket np = new NetworkPacket(ClipboardPlugin.PACKET_TYPE_CLIPBOARD); np.set("content", content); device.sendPacket(np); } private void sendConnectPacket() { String content = ClipboardListener.instance(context).getCurrentContent(); NetworkPacket np = new NetworkPacket(ClipboardPlugin.PACKET_TYPE_CLIPBOARD_CONNECT); long timestamp = ClipboardListener.instance(context).getUpdateTimestamp(); np.set("timestamp", timestamp); np.set("content", content); device.sendPacket(np); } + @Override + public boolean checkRequiredPermissions() { + return Build.VERSION.SDK_INT <= Build.VERSION_CODES.P || PreferenceManager.getDefaultSharedPreferences(context).getBoolean(ANDROID_10_INCOMPAT_DIALOG_SHOWN_PREFERENCE, false); + } + + @Override + public DialogFragment getPermissionExplanationDialog() { + PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(ANDROID_10_INCOMPAT_DIALOG_SHOWN_PREFERENCE, true).apply(); + return new NoticeAlertDialogFragment.Builder() + .setTitle(R.string.pref_plugin_clipboard) + .setMessage(R.string.clipboard_android_x_incompat) + .setPositiveButton(R.string.sad_ok) + .create(); + } @Override public boolean onCreate() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return false; + } ClipboardListener.instance(context).registerObserver(observer); sendConnectPacket(); return true; } @Override public void onDestroy() { ClipboardListener.instance(context).removeObserver(observer); } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_CLIPBOARD, PACKET_TYPE_CLIPBOARD_CONNECT}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_CLIPBOARD, PACKET_TYPE_CLIPBOARD_CONNECT}; } } diff --git a/src/org/kde/kdeconnect/UserInterface/MainActivity.java b/src/org/kde/kdeconnect/UserInterface/MainActivity.java index 55d3566f..ee862e08 100644 --- a/src/org/kde/kdeconnect/UserInterface/MainActivity.java +++ b/src/org/kde/kdeconnect/UserInterface/MainActivity.java @@ -1,389 +1,395 @@ 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.annotation.NonNull; 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; private 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(); preferences = getSharedPreferences("stored_menu_selection", Context.MODE_PRIVATE); // Note: The preference changed listener should be registered before getting the name, because getting // it can trigger a background fetch from the internet that will eventually update the preference PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this); String deviceName = DeviceHelper.getDeviceName(this); mNavViewDeviceName.setText(deviceName); 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.RunCommand(this, service -> { service.onNetworkChange(); service.addDeviceListChangedCallback("MainActivity", this::updateDeviceList); }); updateDeviceList(); } @Override protected void onStop() { 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) { if (requestCode == RESULT_NEEDS_RELOAD) { BackgroundService.RunCommand(this, service -> { Device device = service.getDevice(mCurrentDevice); device.reloadPluginsFromSettings(); }); } else { super.onActivityResult(requestCode, resultCode, data); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { boolean grantedPermission = false; for (int result : grantResults) { if (result == PackageManager.PERMISSION_GRANTED) { grantedPermission = true; break; } } if (grantedPermission) { //New permission granted, reload plugins BackgroundService.RunCommand(this, service -> { Device device = service.getDevice(mCurrentDevice); device.reloadPluginsFromSettings(); }); } } + public void reloadCurrentDevicePlugins() { + BackgroundService.RunCommand(this, service -> { + Device device = service.getDevice(mCurrentDevice); + device.reloadPluginsFromSettings(); + }); + } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (DeviceHelper.KEY_DEVICE_NAME_PREFERENCE.equals(key)) { mNavViewDeviceName.setText(DeviceHelper.getDeviceName(this)); BackgroundService.RunCommand(this, BackgroundService::onNetworkChange); //Re-send our identity packet } } } diff --git a/src/org/kde/kdeconnect/UserInterface/NoticeAlertDialogFragment.java b/src/org/kde/kdeconnect/UserInterface/NoticeAlertDialogFragment.java new file mode 100644 index 00000000..b0c40b6a --- /dev/null +++ b/src/org/kde/kdeconnect/UserInterface/NoticeAlertDialogFragment.java @@ -0,0 +1,68 @@ +/* + * Copyright 2019 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.UserInterface; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import org.kde.kdeconnect_tp.R; + +public class NoticeAlertDialogFragment extends AlertDialogFragment { + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setCallback(new Callback() { + @Override + public void onPositiveButtonClicked() { + //TODO: Find a way to pass this callback from the Builder. For now, this is only used in one place and this is the callback needed. + MainActivity mainActivity = (MainActivity)requireActivity(); + mainActivity.reloadCurrentDevicePlugins(); + } + }); + } + + public static class Builder extends AbstractBuilder { + + public Builder() { + super(); + setTitle(R.string.pref_plugin_clipboard); + setMessage(R.string.clipboard_android_x_incompat); + setPositiveButton(R.string.sad_ok); + } + + @Override + public Builder getThis() { + return this; + } + + @Override + protected NoticeAlertDialogFragment createFragment() { + return new NoticeAlertDialogFragment(); + } + } +}