diff --git a/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhonePlugin.java b/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhonePlugin.java index 61730b14..2cc9ccda 100644 --- a/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhonePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhonePlugin.java @@ -1,85 +1,86 @@ /* * Copyright 2015 David Edmundson * * 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.FindMyPhonePlugin; +import android.app.Activity; import android.content.Intent; import org.kde.kdeconnect.Helpers.DeviceHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; import org.kde.kdeconnect_tp.R; @PluginFactory.LoadablePlugin public class FindMyPhonePlugin extends Plugin { public final static String PACKET_TYPE_FINDMYPHONE_REQUEST = "kdeconnect.findmyphone.request"; @Override public String getDisplayName() { switch (DeviceHelper.getDeviceType(context)) { case Tv: return context.getString(R.string.findmyphone_title_tv); case Tablet: return context.getString(R.string.findmyphone_title_tablet); case Phone: return context.getString(R.string.findmyphone_title); default: return context.getString(R.string.findmyphone_title); } } @Override public String getDescription() { return context.getString(R.string.findmyphone_description); } @Override public boolean onPacketReceived(NetworkPacket np) { Intent intent = new Intent(context, FindMyPhoneActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); return true; } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_FINDMYPHONE_REQUEST}; } @Override public String[] getOutgoingPacketTypes() { return new String[0]; } @Override public boolean hasSettings() { return true; } @Override - public PluginSettingsFragment getSettingsFragment() { + public PluginSettingsFragment getSettingsFragment(Activity activity) { return FindMyPhoneSettingsFragment.newInstance(getPluginKey()); } } diff --git a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java index 92a925ba..33a46528 100644 --- a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java @@ -1,594 +1,592 @@ /* * 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.Activity; 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() { + public PluginSettingsFragment getSettingsFragment(Activity activity) { if (hasPermission()) { - Context context = device.getContext(); - Intent intent = new Intent(context, NotificationFilterActivity.class); - context.startActivity(intent); + Intent intent = new Intent(activity, NotificationFilterActivity.class); + activity.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 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(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 1ec21766..9a9e3ab9 100644 --- a/src/org/kde/kdeconnect/Plugins/Plugin.java +++ b/src/org/kde/kdeconnect/Plugins/Plugin.java @@ -1,262 +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() { + public PluginSettingsFragment getSettingsFragment(Activity activity) { 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 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/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java index 8ad4aca0..b227027d 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java @@ -1,433 +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.Plugins.SharePlugin; import android.Manifest; import android.app.Activity; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ClipboardManager; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.provider.MediaStore; import android.util.Log; import android.widget.Toast; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; import org.kde.kdeconnect_tp.R; import java.io.File; import java.io.InputStream; import java.net.URL; import java.util.ArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import androidx.annotation.WorkerThread; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; @PluginFactory.LoadablePlugin public class SharePlugin extends Plugin { private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request"; final static String PACKET_TYPE_SHARE_REQUEST_UPDATE = "kdeconnect.share.request.update"; final static String KEY_NUMBER_OF_FILES = "numberOfFiles"; final static String KEY_TOTAL_PAYLOAD_SIZE = "totalPayloadSize"; private final static boolean openUrlsDirectly = true; private ExecutorService executorService; private final Handler handler; CompositeReceiveFileRunnable receiveFileRunnable; private final Callback receiveFileRunnableCallback; public SharePlugin() { executorService = Executors.newFixedThreadPool(5); handler = new Handler(Looper.getMainLooper()); receiveFileRunnableCallback = new Callback(); } @Override public boolean onCreate() { optionalPermissionExplanation = R.string.share_optional_permission_explanation; return true; } @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_sharereceiver); } @Override public Drawable getIcon() { return ContextCompat.getDrawable(context, R.drawable.share_plugin_action); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_sharereceiver_desc); } @Override public boolean hasMainActivity() { return true; } @Override public String getActionName() { return context.getString(R.string.send_files); } @Override public void startMainActivity(Activity parentActivity) { Intent intent = new Intent(parentActivity, SendFileActivity.class); intent.putExtra("deviceId", device.getDeviceId()); parentActivity.startActivity(intent); } @Override public boolean hasSettings() { return true; } @Override @WorkerThread public boolean onPacketReceived(NetworkPacket np) { try { if (np.getType().equals(PACKET_TYPE_SHARE_REQUEST_UPDATE)) { if (receiveFileRunnable != null && receiveFileRunnable.isRunning()) { receiveFileRunnable.updateTotals(np.getInt(KEY_NUMBER_OF_FILES), np.getLong(KEY_TOTAL_PAYLOAD_SIZE)); } else { Log.d("SharePlugin", "Received update packet but CompositeUploadJob is null or not running"); } return true; } if (np.has("filename")) { if (isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { receiveFile(np); } else { Log.i("SharePlugin", "no Permission for Storage"); } } else if (np.has("text")) { Log.i("SharePlugin", "hasText"); receiveText(np); } else if (np.has("url")) { receiveUrl(np); } else { Log.e("SharePlugin", "Error: Nothing attached!"); } } catch (Exception e) { Log.e("SharePlugin", "Exception"); e.printStackTrace(); } return true; } private void receiveUrl(NetworkPacket np) { String url = np.getString("url"); Log.i("SharePlugin", "hasUrl: " + url); Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); if (openUrlsDirectly) { context.startActivity(browserIntent); } else { Resources res = context.getResources(); PendingIntent resultPendingIntent = PendingIntent.getActivity( context, 0, browserIntent, PendingIntent.FLAG_UPDATE_CURRENT ); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); Notification noti = new NotificationCompat.Builder(context, NotificationHelper.Channels.DEFAULT) .setContentTitle(res.getString(R.string.received_url_title, device.getName())) .setContentText(res.getString(R.string.received_url_text, url)) .setContentIntent(resultPendingIntent) .setTicker(res.getString(R.string.received_url_title, device.getName())) .setSmallIcon(R.drawable.ic_notification) .setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL) .build(); NotificationHelper.notifyCompat(notificationManager, (int) System.currentTimeMillis(), noti); } } private void receiveText(NetworkPacket np) { String text = np.getString("text"); ClipboardManager cm = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); cm.setText(text); handler.post(() -> Toast.makeText(context, R.string.shareplugin_text_saved, Toast.LENGTH_LONG).show()); } @WorkerThread private void receiveFile(NetworkPacket np) { CompositeReceiveFileRunnable runnable; boolean hasNumberOfFiles = np.has(KEY_NUMBER_OF_FILES); boolean hasOpen = np.has("open"); if (hasNumberOfFiles && !hasOpen && receiveFileRunnable != null) { runnable = receiveFileRunnable; } else { runnable = new CompositeReceiveFileRunnable(device, receiveFileRunnableCallback); } if (!hasNumberOfFiles) { np.set(KEY_NUMBER_OF_FILES, 1); np.set(KEY_TOTAL_PAYLOAD_SIZE, np.getPayloadSize()); } runnable.addNetworkPacket(np); if (runnable != receiveFileRunnable) { if (hasNumberOfFiles && !hasOpen) { receiveFileRunnable = runnable; } executorService.execute(runnable); } } @Override - public PluginSettingsFragment getSettingsFragment() { + public PluginSettingsFragment getSettingsFragment(Activity activity) { return ShareSettingsFragment.newInstance(getPluginKey()); } void queuedSendUriList(final ArrayList uriList) { //Read all the data early, as we only have permissions to do it while the activity is alive final ArrayList toSend = new ArrayList<>(); for (Uri uri : uriList) { NetworkPacket np = uriToNetworkPacket(context, uri); if (np != null) { toSend.add(np); } } //Callback that shows a progress notification final NotificationUpdateCallback notificationUpdateCallback = new NotificationUpdateCallback(context, device, toSend); //Do the sending in background new Thread(() -> { //Actually send the files try { for (NetworkPacket np : toSend) { boolean success = device.sendPacketBlocking(np, notificationUpdateCallback); if (!success) { Log.e("SharePlugin", "Error sending files"); return; } } } catch (Exception e) { e.printStackTrace(); } }).start(); } //Create the network package from the URI private static NetworkPacket uriToNetworkPacket(final Context context, final Uri uri) { try { ContentResolver cr = context.getContentResolver(); InputStream inputStream = cr.openInputStream(uri); NetworkPacket np = new NetworkPacket(PACKET_TYPE_SHARE_REQUEST); long size = -1; if (uri.getScheme().equals("file")) { // file:// is a non media uri, so we cannot query the ContentProvider np.set("filename", uri.getLastPathSegment()); try { size = new File(uri.getPath()).length(); } catch (Exception e) { Log.e("SendFileActivity", "Could not obtain file size"); e.printStackTrace(); } } else { // Probably a content:// uri, so we query the Media content provider Cursor cursor = null; try { String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.DISPLAY_NAME}; cursor = cr.query(uri, proj, null, null, null); int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); cursor.moveToFirst(); String path = cursor.getString(column_index); np.set("filename", Uri.parse(path).getLastPathSegment()); size = new File(path).length(); } catch (Exception unused) { Log.w("SendFileActivity", "Could not resolve media to a file, trying to get info as media"); try { int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME); cursor.moveToFirst(); String name = cursor.getString(column_index); np.set("filename", name); } catch (Exception e) { e.printStackTrace(); Log.e("SendFileActivity", "Could not obtain file name"); } try { int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE); cursor.moveToFirst(); //For some reason this size can differ from the actual file size! size = cursor.getInt(column_index); } catch (Exception e) { Log.e("SendFileActivity", "Could not obtain file size"); e.printStackTrace(); } } finally { try { cursor.close(); } catch (Exception ignored) { } } } np.setPayload(new NetworkPacket.Payload(inputStream, size)); return np; } catch (Exception e) { Log.e("SendFileActivity", "Exception sending files"); e.printStackTrace(); return null; } } public void share(Intent intent) { Bundle extras = intent.getExtras(); if (extras != null) { if (extras.containsKey(Intent.EXTRA_STREAM)) { try { ArrayList uriList; if (!Intent.ACTION_SEND.equals(intent.getAction())) { uriList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); } else { Uri uri = extras.getParcelable(Intent.EXTRA_STREAM); uriList = new ArrayList<>(); uriList.add(uri); } queuedSendUriList(uriList); } catch (Exception e) { Log.e("ShareActivity", "Exception"); e.printStackTrace(); } } else if (extras.containsKey(Intent.EXTRA_TEXT)) { String text = extras.getString(Intent.EXTRA_TEXT); String subject = extras.getString(Intent.EXTRA_SUBJECT); //Hack: Detect shared youtube videos, so we can open them in the browser instead of as text if (subject != null && subject.endsWith("YouTube")) { int index = text.indexOf(": http://youtu.be/"); if (index > 0) { text = text.substring(index + 2); //Skip ": " } } boolean isUrl; try { new URL(text); isUrl = true; } catch (Exception e) { isUrl = false; } NetworkPacket np = new NetworkPacket(SharePlugin.PACKET_TYPE_SHARE_REQUEST); if (isUrl) { np.set("url", text); } else { np.set("text", text); } device.sendPacket(np); } } } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_SHARE_REQUEST, PACKET_TYPE_SHARE_REQUEST_UPDATE}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_SHARE_REQUEST}; } @Override public String[] getOptionalPermissions() { return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; } private class Callback implements CompositeReceiveFileRunnable.CallBack { @Override public void onSuccess(CompositeReceiveFileRunnable runnable) { if (runnable == receiveFileRunnable) { receiveFileRunnable = null; } } @Override public void onError(CompositeReceiveFileRunnable runnable, Throwable error) { Log.e("SharePlugin", "onError() - " + error.getMessage()); if (runnable == receiveFileRunnable) { receiveFileRunnable = null; } } } } diff --git a/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java b/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java index 5f79c743..e03d6a89 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); - fragment = plugin.getSettingsFragment(); + fragment = plugin.getSettingsFragment(this); } 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(); + PluginSettingsFragment fragment = plugin.getSettingsFragment(this); //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(); } }