diff --git a/res/layout/popup_notificationsfilter.xml b/res/layout/popup_notificationsfilter.xml
new file mode 100644
index 00000000..7d82d25f
--- /dev/null
+++ b/res/layout/popup_notificationsfilter.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/res/layout/privacy_options.xml b/res/layout/privacy_options.xml
new file mode 100644
index 00000000..c09d09c9
--- /dev/null
+++ b/res/layout/privacy_options.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/res/values/strings.xml b/res/values/strings.xml
index b7d53baf..5fa5a973 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1,321 +1,328 @@
KDE Connect
Not connected to any device
Connected to: %s
Telephony notifier
Send notifications for incoming calls
Battery report
Periodically report battery status
Filesystem expose
Allows to browse this device\'s filesystem remotely
Clipboard sync
Share the clipboard content
Remote input
Use your phone or tablet as a touchpad and keyboard
Presentation remote
Use your device to change slides in a presentation
Receive remote keypresses
Receive keypress events from remote devices
Multimedia controls
Provides a remote control for your media player
Run Command
Trigger remote commands from your phone or tablet
Contacts Synchronizer
Allow synchronizing the device\'s contacts book
Ping
Send and receive pings
Notification sync
Access your notifications from other devices
Receive notifications
Receive notifications from the other device and display them on Android
Share and receive
Share files and URLs between devices
This feature is not available in your Android version
No devices
OK
Cancel
Open settings
You need to grant permission to access notifications
To be able to control your media players you need to grant access to the notifications
Send ping
Multimedia control
remotekeyboard_editing_only
Handle remote keys only when editing
There is no active remote keyboard connection, establish one in kdeconnect
Remote keyboard connection is active
There is more than one remote keyboard connection, select the device to configure
Remote input
Move a finger on the screen to move the mouse cursor. Tap for a click, and use two/three fingers for right and middle buttons. Use 2 fingers to scroll. Use a long press to drag\'n drop.
Set two finger tap action
Set three finger tap action
Set touchpad sensitivity
Set pointer acceleration
mousepad_double_tap_key
mousepad_triple_tap_key
mousepad_sensitivity_key
mousepad_acceleration_profile_key
Reverse Scrolling Direction
mousepad_scroll_direction
- Right click
- Middle click
- Nothing
right
middle
default
medium
- right
- middle
- none
- Slowest
- Above Slowest
- Default
- Above Default
- Fastest
- No Acceleration
- Weakest
- Weaker
- Medium
- Stronger
- Strongest
- slowest
- aboveSlowest
- default
- aboveDefault
- fastest
- noacceleration
- weaker
- weak
- medium
- strong
- stronger
Connected devices
Available devices
Remembered devices
Plugins failed to load (tap for more info):
Plugin settings
Unpair
Paired device not reachable
Pair new device
Unknown device
Device not reachable
Pairing already requested
Device already paired
Could not send package
Timed out
Canceled by user
Canceled by other peer
Invalid key received
Encryption Info
The other device doesn\'t use a recent version of KDE Connect, using the legacy encryption method.
SHA1 fingerprint of your device certificate is:
SHA1 fingerprint of remote device certificate is:
Pair requested
Pairing request from %1s
Received link from %1s
Tap to open \'%1s\'
Receiving file from %1s>
- Receiving %1$d file from %2$s
- Receiving %1$d files from %2$s
- File: %1s
- (File %2$d of %3$d) : %1$s
Sending file to %1s
Sending files to %1s
- Sent %1$d file
- Sent %1$d out of %2$d files
- Received file from %2$s
- Received %1$d files from %2$s
- Failed receiving file from %3$s
- Failed receiving %1$d of %2$d files from %3$s
Tap to open \'%1s\'
Cannot create file %s
Sent file to %1s
%1s
Failed to send file to %1s
%1s
Tap to answer
Reconnect
Send Right Click
Send Middle Click
Show Keyboard
Device not paired
Request pairing
Accept
Reject
Device
Pair device
Remote control
Settings
Play
Pause
Previous
Rewind
Fast-forward
Next
Volume
Multimedia Settings
Forward/rewind buttons
Adjust the time to fast forward/rewind when pressed
mpris_interval_time
- 10 seconds
- 20 seconds
- 30 seconds
- 1 minute
- 2 minutes
10000000
- 10000000
- 20000000
- 30000000
- 60000000
- 120000000
Show media control notification
Allow controlling your media players without opening KDE Connect
mpris_notification_enabled
Share To…
This device uses an old protocol version
This device uses a newer protocol version
General Settings
Settings
%s settings
Device name
%s
Invalid device name
Received text, saved to clipboard
Custom device list
Pair a new device
Unpair %s
Add devices by IP
Delete %s?
Noisy notifications
Vibrate and play a sound when receiving a file
Customize destination directory
Received files will appear in Downloads
Files will be stored in the directory below
Destination directory
Share
Share \"%s\"
Notification filter
Notifications will be synchronized for the selected apps.
Internal storage
All files
SD card %d
SD card
(read only)
Camera pictures
Add host/IP
Hostname or IP
No players found
Use this option only if your device is not automatically detected. Enter IP address or hostname below and touch the button to add it to the list. Touch an existing item to remove it from the list.
%1$s on %2$s
Send files
KDE Connect Devices
Other devices running KDE Connect in your same network should appear here.
Device paired
Rename device
Rename
Refresh
This paired device is not reachable. Make sure it is connected to your same network.
It looks like you are on a mobile data connection. KDE Connect only works on local networks.
There are no file browsers installed.
Send SMS
Send text messages from your desktop
This plugin is not supported by the device
Find my phone
Find my tablet
Find my TV
Rings this device so you can find it
Found
Open
Close
You need to grant permissions to access the storage
Some Plugins need permissions to work (tap for more info):
This plugin needs permissions to work
You need to grant extra permissions to enable all functions
Some plugins have features disabled because of lack of permission (tap for more info):
To access your files from your PC the app needs permission to access your phone\'s storage
To share files between your phone and your desktop you need to give access to the phone\'s storage
To read and write SMS from your desktop you need to give permission to SMS
To see phone calls and SMS from the desktop you need to give permission to phone calls and SMS
To see a contact name instead of a phone number you need to give access to the phone\'s contacts
To share your contacts book with the desktop, you need to give contacts permission
Select a ringtone
Blocked numbers
Don\'t show calls and SMS from these numbers. Please specify one number per line
Cover art of current media
Device icon
Settings icon
Fullscreen
Exit presentation
You can lock your device to use the volume keys as previous/next buttons
Add a command
There are no commands registered
You can add new commands in the KDE Connect System Settings
You can add commands on the desktop
Media Player Control
Control your phones media players from another device
Dark theme
Other notifications
Persistent indicator
Media control
File transfer
Stop the current player
Copy URL to clipboard
Copied to clipboard
Device is not reachable
Device is not paired
There is no such device
This device does not have the Run Command Plugin enabled
Find remote device
Ring your remote device
Ring
System volume
Control the system volume of the remote device
Mute
All
Devices
Device name
Dark theme
More settings
Per-device settings can be found under \'Plugin settings\' from within a device.
Show persistent notification
Required by Android since Android 8.0
Since Android 9.0, this notification can only be minimized by long tapping on it
+ Extra options
+ Privacy options
+ Set your privacy options
+ New notification
+ Block contents of notifications
+ Block images in notifications
+
diff --git a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/AppDatabase.java b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/AppDatabase.java
index f1710340..52563397 100644
--- a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/AppDatabase.java
+++ b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/AppDatabase.java
@@ -1,132 +1,212 @@
/*
* Copyright 2015 Vineet Garg
*
* 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.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
+import android.util.Log;
import java.util.HashSet;
class AppDatabase {
static final private HashSet disabledByDefault = new HashSet<>();
static {
disabledByDefault.add("com.google.android.googlequicksearchbox"); //Google Now notifications re-spawn every few minutes
}
private static final String SETTINGS_NAME = "app_database";
private static final String SETTINGS_KEY_ALL_ENABLED = "all_enabled";
private static final int DATABASE_VERSION = 4;
private static final String DATABASE_NAME = "Applications";
- private static final String DATABASE_TABLE = "Applications";
+ private static final String TABLE_ENABLED = "Applications";
+ private static final String TABLE_PRIVACY = "PrivacyOpts";
private static final String KEY_PACKAGE_NAME = "packageName";
private static final String KEY_IS_ENABLED = "isEnabled";
+ private static final String KEY_PRIVACY_OPTIONS = "privacyOptions";
private SQLiteDatabase ourDatabase;
private DbHelper ourHelper;
private SharedPreferences prefs;
AppDatabase(Context context, boolean readonly) {
ourHelper = new DbHelper(context);
prefs = context.getSharedPreferences(SETTINGS_NAME, Context.MODE_PRIVATE);
if (readonly) {
ourDatabase = ourHelper.getReadableDatabase();
} else {
ourDatabase = ourHelper.getWritableDatabase();
}
}
@Override
protected void finalize() throws Throwable {
ourHelper.close();
super.finalize();
}
private static class DbHelper extends SQLiteOpenHelper {
DbHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
- db.execSQL("CREATE TABLE " + DATABASE_TABLE + "(" + KEY_PACKAGE_NAME + " TEXT PRIMARY KEY NOT NULL, " + KEY_IS_ENABLED + " INTEGER NOT NULL); ");
+ db.execSQL("CREATE TABLE " + TABLE_ENABLED +
+ "(" + KEY_PACKAGE_NAME + " TEXT PRIMARY KEY NOT NULL, " +
+ KEY_IS_ENABLED + " INTEGER NOT NULL ); ");
+ db.execSQL("CREATE TABLE " + TABLE_PRIVACY +
+ "(" + KEY_PACKAGE_NAME + " TEXT PRIMARY KEY NOT NULL, " +
+ KEY_PRIVACY_OPTIONS + " INTEGER NOT NULL); ");
}
@Override
public void onUpgrade(SQLiteDatabase db, int i, int i2) {
- db.execSQL("DROP TABLE IF EXISTS " + DATABASE_TABLE);
+ db.execSQL("DROP TABLE IF EXISTS " + TABLE_ENABLED);
onCreate(db);
}
}
void setEnabled(String packageName, boolean isEnabled) {
String[] columns = new String[]{KEY_IS_ENABLED};
- try (Cursor res = ourDatabase.query(DATABASE_TABLE, columns, KEY_PACKAGE_NAME + " =? ", new String[]{packageName}, null, null, null)) {
+ try (Cursor res = ourDatabase.query(TABLE_ENABLED, columns, KEY_PACKAGE_NAME + " =? ", new String[]{packageName}, null, null, null)) {
ContentValues cv = new ContentValues();
cv.put(KEY_IS_ENABLED, isEnabled ? 1 : 0);
if (res.getCount() > 0) {
- ourDatabase.update(DATABASE_TABLE, cv, KEY_PACKAGE_NAME + "=?", new String[]{packageName});
+ ourDatabase.update(TABLE_ENABLED, cv, KEY_PACKAGE_NAME + "=?", new String[]{packageName});
} else {
cv.put(KEY_PACKAGE_NAME, packageName);
- ourDatabase.insert(DATABASE_TABLE, null, cv);
+ long retVal = ourDatabase.insert(TABLE_ENABLED, null, cv);
+ Log.i("AppDatabase", "SetEnabled retval = " + retVal);
}
}
}
boolean getAllEnabled() {
return prefs.getBoolean(SETTINGS_KEY_ALL_ENABLED, true);
}
void setAllEnabled(boolean enabled) {
prefs.edit().putBoolean(SETTINGS_KEY_ALL_ENABLED, enabled).apply();
- ourDatabase.execSQL("UPDATE " + DATABASE_TABLE + " SET " + KEY_IS_ENABLED + "=" + (enabled? "1" : "0"));
+ ourDatabase.execSQL("UPDATE " + TABLE_ENABLED + " SET " + KEY_IS_ENABLED + "=" + (enabled? "1" : "0"));
}
boolean isEnabled(String packageName) {
String[] columns = new String[]{KEY_IS_ENABLED};
- try (Cursor res = ourDatabase.query(DATABASE_TABLE, columns, KEY_PACKAGE_NAME + " =? ", new String[]{packageName}, null, null, null)) {
+ try (Cursor res = ourDatabase.query(TABLE_ENABLED, columns, KEY_PACKAGE_NAME + " =? ", new String[]{packageName}, null, null, null)) {
boolean result;
if (res.getCount() > 0) {
res.moveToFirst();
result = (res.getInt(res.getColumnIndex(KEY_IS_ENABLED)) != 0);
} else {
result = getDefaultStatus(packageName);
}
return result;
}
}
private boolean getDefaultStatus(String packageName) {
if (disabledByDefault.contains(packageName)) {
return false;
}
return getAllEnabled();
}
+ public enum PrivacyOptions {
+ BLOCK_CONTENTS,
+ BLOCK_IMAGES
+ // Just add new enum to add a new privacy option.
+ }
+
+ private int getPrivacyOptionsValue(String packageName)
+ {
+ String[] columns = new String[]{KEY_PRIVACY_OPTIONS};
+ try (Cursor res = ourDatabase.query(TABLE_PRIVACY, columns, KEY_PACKAGE_NAME + " =? ", new String[]{packageName}, null, null, null)) {
+ int result;
+ if (res.getCount() > 0) {
+ res.moveToFirst();
+ result = res.getInt(res.getColumnIndex(KEY_PRIVACY_OPTIONS));
+ } else {
+ result = 0;
+ }
+ return result;
+ }
+ }
+
+ private void setPrivacyOptionsValue(String packageName, int value) {
+ String[] columns = new String[]{KEY_PRIVACY_OPTIONS};
+ try (Cursor res = ourDatabase.query(TABLE_PRIVACY, columns, KEY_PACKAGE_NAME + " =? ", new String[]{packageName}, null, null, null)) {
+ ContentValues cv = new ContentValues();
+ cv.put(KEY_PRIVACY_OPTIONS, value);
+ if (res.getCount() > 0) {
+ ourDatabase.update(TABLE_PRIVACY, cv, KEY_PACKAGE_NAME + "=?", new String[]{packageName});
+ } else {
+ cv.put(KEY_PACKAGE_NAME, packageName);
+ long retVal = ourDatabase.insert(TABLE_PRIVACY, null, cv);
+ Log.i("AppDatabase", "SetPrivacyOptions retval = " + retVal);
+ }
+ }
+ }
+
+ /**
+ * Set privacy option of an app.
+ * @param packageName name of the app
+ * @param option option of PrivacyOptions enum, that we set the value of.
+ * @param isBlocked boolean, if user wants to block an option.
+ */
+ public void setPrivacy(String packageName, PrivacyOptions option, boolean isBlocked) {
+ // Bit, that we want to change
+ int curBit = option.ordinal();
+ // Current value of privacy options
+ int value = getPrivacyOptionsValue(packageName);
+ // Make the selected bit '1'
+ value |= (1 << curBit);
+ // If we want to block an option, we set the selected bit to '0'.
+ value ^= isBlocked ? 0 : (1 << curBit);
+ // Update the value
+ setPrivacyOptionsValue(packageName, value);
+ }
+
+ /**
+ * Get privacy option of an app.
+ * @param packageName name of the app
+ * @param option option of PrivacyOptions enum, that we set the value of.
+ * @return returns true if the option is blocking.
+ */
+ public boolean getPrivacy(String packageName, PrivacyOptions option) {
+ // Bit, that we want to change
+ int curBit = option.ordinal();
+ // Current value of privacy options
+ int value = getPrivacyOptionsValue(packageName);
+ // Read the bit
+ int bit = value & (1 << curBit);
+ // If that bit was 0, the bit variable is 0. If not, it's some power of 2.
+ return bit != 0;
+ }
}
diff --git a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationFilterActivity.java b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationFilterActivity.java
index 193b0b56..9ce5032d 100644
--- a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationFilterActivity.java
+++ b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationFilterActivity.java
@@ -1,187 +1,248 @@
/*
* Copyright 2015 Vineet Garg
*
* 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.app.AlertDialog;
+import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
import android.widget.BaseAdapter;
+import android.widget.CheckBox;
import android.widget.CheckedTextView;
import android.widget.ListView;
import org.kde.kdeconnect.BackgroundService;
+import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.StringsHelper;
import org.kde.kdeconnect.UserInterface.ThemeUtil;
import org.kde.kdeconnect_tp.R;
import java.util.Arrays;
import java.util.List;
public class NotificationFilterActivity extends AppCompatActivity {
private AppDatabase appDatabase;
private ListView listView;
static class AppListInfo {
String pkg;
String name;
Drawable icon;
boolean isEnabled;
}
private AppListInfo[] apps;
class AppListAdapter extends BaseAdapter {
@Override
public int getCount() {
return apps.length + 1;
}
@Override
public AppListInfo getItem(int position) {
return apps[position - 1];
}
@Override
public long getItemId(int position) {
return position - 1;
}
public View getView(int position, View view, ViewGroup parent) {
if (view == null) {
LayoutInflater inflater = getLayoutInflater();
view = inflater.inflate(android.R.layout.simple_list_item_multiple_choice, null, true);
}
CheckedTextView checkedTextView = (CheckedTextView) view;
if (position == 0) {
checkedTextView.setText(R.string.all);
checkedTextView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
} else {
checkedTextView.setText(apps[position - 1].name);
checkedTextView.setCompoundDrawablesWithIntrinsicBounds(apps[position - 1].icon, null, null, null);
checkedTextView.setCompoundDrawablePadding((int) (8 * getResources().getDisplayMetrics().density));
}
return view;
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeUtil.setUserPreferredTheme(this);
setContentView(R.layout.activity_notification_filter);
appDatabase = new AppDatabase(NotificationFilterActivity.this, false);
new Thread(() -> {
PackageManager packageManager = getPackageManager();
List appList = packageManager.getInstalledApplications(0);
int count = appList.size();
apps = new AppListInfo[count];
for (int i = 0; i < count; i++) {
ApplicationInfo appInfo = appList.get(i);
apps[i] = new AppListInfo();
apps[i].pkg = appInfo.packageName;
apps[i].name = appInfo.loadLabel(packageManager).toString();
apps[i].icon = resizeIcon(appInfo.loadIcon(packageManager), 48);
apps[i].isEnabled = appDatabase.isEnabled(appInfo.packageName);
}
Arrays.sort(apps, (lhs, rhs) -> StringsHelper.compare(lhs.name, rhs.name));
runOnUiThread(this::displayAppList);
}).start();
}
private void displayAppList() {
listView = findViewById(R.id.lvFilterApps);
AppListAdapter adapter = new AppListAdapter();
listView.setAdapter(adapter);
listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+ listView.setLongClickable(true);
listView.setOnItemClickListener((adapterView, view, i, l) -> {
if (i == 0) {
boolean enabled = listView.isItemChecked(0);
for (int j = 0; j < apps.length; j++) {
listView.setItemChecked(j, enabled);
}
appDatabase.setAllEnabled(enabled);
} else {
boolean checked = listView.isItemChecked(i);
appDatabase.setEnabled(apps[i - 1].pkg, checked);
apps[i - 1].isEnabled = checked;
}
});
+ listView.setOnItemLongClickListener((adapterView, view, i, l) -> {
+ if(i == 0)
+ return true;
+ Context context = this;
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ View mView = getLayoutInflater().inflate(R.layout.popup_notificationsfilter, null);
+ builder.setMessage(context.getResources().getString(R.string.extra_options));
+
+ ListView lv = mView.findViewById(R.id.extra_options_list);
+ final String[] options = new String[] {
+ context.getResources().getString(R.string.privacy_options)
+ };
+ ArrayAdapter extra_options_adapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_list_item_1, options);
+ lv.setAdapter(extra_options_adapter);
+ builder.setView(mView);
+
+ AlertDialog ad = builder.create();
+
+ lv.setOnItemClickListener((new_adapterView, new_view, new_i, new_l) -> {
+ switch (new_i){
+ case 0:
+ AlertDialog.Builder myBuilder = new AlertDialog.Builder(context);
+ String packageName = apps[i - 1].pkg;
+
+ View myView = getLayoutInflater().inflate(R.layout.privacy_options, null);
+ CheckBox checkbox_contents = myView.findViewById(R.id.checkbox_contents);
+ checkbox_contents.setChecked(appDatabase.getPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_CONTENTS));
+ checkbox_contents.setText(context.getResources().getString(R.string.block_contents));
+ CheckBox checkbox_images = myView.findViewById(R.id.checkbox_images);
+ checkbox_images.setChecked(appDatabase.getPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_IMAGES));
+ checkbox_images.setText(context.getResources().getString(R.string.block_images));
+
+ myBuilder.setView(myView);
+ myBuilder.setTitle(context.getResources().getString(R.string.privacy_options));
+ myBuilder.setPositiveButton(context.getResources().getString(R.string.ok), (dialog, id) -> dialog.dismiss());
+ myBuilder.setMessage(context.getResources().getString(R.string.set_privacy_options));
+
+ checkbox_contents.setOnCheckedChangeListener((compoundButton, b) ->
+ appDatabase.setPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_CONTENTS,
+ compoundButton.isChecked()));
+ checkbox_images.setOnCheckedChangeListener((compoundButton, b) ->
+ appDatabase.setPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_IMAGES,
+ compoundButton.isChecked()));
+
+ ad.cancel();
+ myBuilder.create().show();
+ break;
+ }
+ });
+
+ ad.show();
+ return true;
+ });
listView.setItemChecked(0, appDatabase.getAllEnabled()); //"Select all" button
for (int i = 0; i < apps.length; i++) {
listView.setItemChecked(i + 1, apps[i].isEnabled);
}
listView.setVisibility(View.VISIBLE);
findViewById(R.id.spinner).setVisibility(View.GONE);
}
@Override
protected void onStart() {
super.onStart();
BackgroundService.addGuiInUseCounter(this);
}
@Override
protected void onStop() {
super.onStop();
BackgroundService.removeGuiInUseCounter(this);
}
private Drawable resizeIcon(Drawable icon, int maxSize) {
Resources res = getResources();
//Convert to display pixels
maxSize = (int) (maxSize * res.getDisplayMetrics().density);
Bitmap bitmap = Bitmap.createBitmap(maxSize, maxSize, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
icon.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
icon.draw(canvas);
return new BitmapDrawable(res, bitmap);
}
}
diff --git a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java
index 4139e4d3..4d9e16d8 100644
--- a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java
+++ b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java
@@ -1,591 +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 .
+ * along with this program. If not, see .
*/
package org.kde.kdeconnect.Plugins.NotificationsPlugin;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.AlertDialog;
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.support.annotation.RequiresApi;
import android.support.v4.app.NotificationCompat;
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.UserInterface.DeviceSettingsActivity;
import org.kde.kdeconnect.UserInterface.MainActivity;
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;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
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 void startPreferencesActivity(final DeviceSettingsActivity parentActivity) {
if (hasPermission()) {
Intent intent = new Intent(parentActivity, NotificationFilterActivity.class);
parentActivity.startActivity(intent);
} else {
getErrorDialog(parentActivity).show();
}
}
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;
}
NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION);
if (packageName.equals("org.kde.kdeconnect_tp")) {
//Make our own notifications silent :)
np.set("silent", true);
np.set("requestAnswer", true); //For compatibility with old desktop versions of KDE Connect that don't support "silent"
}
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 = null;
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;
}
//appIcon = drawableToBitmap(context.getResources().getDrawable(R.drawable.icon));
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) {
+ 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);
}
- RepliableNotification rn = extractRepliableNotification(statusBarNotification);
- if (rn.pendingIntent != null) {
- np.set("requestReplyId", rn.id);
- pendingIntents.put(rn.id, rn);
- }
-
np.set("id", key);
- np.set("appName", appName == null ? packageName : appName);
np.set("isClearable", statusBarNotification.isClearable());
- np.set("ticker", getTickerText(notification));
- np.set("title", getNotificationTitle(notification));
- np.set("text", getNotificationText(notification));
+ 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 AlertDialog getErrorDialog(final Activity deviceActivity) {
return new AlertDialog.Builder(deviceActivity)
.setTitle(R.string.pref_plugin_notifications)
.setMessage(R.string.no_permissions)
.setPositiveButton(R.string.open_settings, (dialogInterface, i) -> {
Intent intent = new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS");
deviceActivity.startActivityForResult(intent, MainActivity.RESULT_NEEDS_RELOAD);
})
.setNegativeButton(R.string.cancel, (dialogInterface, i) -> {
//Do nothing
})
.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;
}
}