commit 0ab16f67473fbba71d812baaf279acf194b97d10 Author: Dave Umrysh Date: Tue Mar 9 21:10:33 2021 -0700 first commit diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..51fca54 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,11 @@ +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..807df0a --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Reply to notifications plugin for Cordova + +This plugin relies heavily on the great work competed by Javier Rengel and their [NotificationListenerService plugin for Cordova](https://git.umycode.com/dave/NotificationListener-cordova) + +This plugin was built with the explicit intent of being used in my 8x8 to Matrix bridge application and therefore will only store the notification intents from app "org.vom8x8.sipua". + +If you find it useful please feel free to use it for your own purposes. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1cdc3fa --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "name": "cordova-plugin-reply-to-notification", + "cordova_name": "cordova-plugin-reply-to-notification", + "description": "Reply to notifications plugin for Cordova", + "license": "Apache 2.0", + "repo": "https://git.umycode.com/dave/cordova-plugin-reply-to-notification", + "issue": "https://git.umycode.com/dave/cordova-plugin-reply-to-notification/issues", + "keywords": [ + "notification", + "listener", + "android" + ], + "platforms": [ + "android" + ], + "engines": [ + { + "name": "cordova", + "version": ">=3.1.0" + } + ] +} diff --git a/plugin.xml b/plugin.xml new file mode 100644 index 0000000..8f9a9fa --- /dev/null +++ b/plugin.xml @@ -0,0 +1,53 @@ + + + + cordova-plugin-reply-to-notification + Reply to notifications plugin for Cordova + Apache 2.0 + notification, listener, android + + https://git.umycode.com/dave/cordova-plugin-reply-to-notification + https://git.umycode.com/dave/cordova-plugin-reply-to-notification/issues + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/Action.java b/src/android/Action.java new file mode 100644 index 0000000..a6ed076 --- /dev/null +++ b/src/android/Action.java @@ -0,0 +1,117 @@ +package com.umycode.replyToNotification; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.RemoteInput; +import android.util.Log; + +import java.util.ArrayList; + +import com.umycode.replyToNotification.RemoteInputParcel; + +public class Action implements Parcelable { + + private final String text; + private final String packageName; + private final PendingIntent p; + private final boolean isQuickReply; + private final ArrayList remoteInputs = new ArrayList<>(); + + public Action(Parcel in) { + text = in.readString(); + packageName = in.readString(); + p = in.readParcelable(PendingIntent.class.getClassLoader()); + isQuickReply = in.readByte() != 0; + in.readTypedList(remoteInputs, RemoteInputParcel.CREATOR); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(text); + dest.writeString(packageName); + dest.writeParcelable(p, flags); + dest.writeByte((byte) (isQuickReply ? 1 : 0)); + dest.writeTypedList(remoteInputs); + } + + public Action(String text, String packageName, PendingIntent p, RemoteInput remoteInput, boolean isQuickReply) { + this.text = text; + this.packageName = packageName; + this.p = p; + this.isQuickReply = isQuickReply; + remoteInputs.add(new RemoteInputParcel(remoteInput)); + } + + public Action(NotificationCompat.Action action, String packageName, boolean isQuickReply) { + this.text = action.title.toString(); + this.packageName = packageName; + this.p = action.actionIntent; + if(action.getRemoteInputs() != null) { + int size = action.getRemoteInputs().length; + for(int i = 0; i < size; i++) + remoteInputs.add(new RemoteInputParcel(action.getRemoteInputs()[i])); + } + this.isQuickReply = isQuickReply; + } + + public void sendReply(Context context, String msg) throws PendingIntent.CanceledException { + Intent intent = new Intent(); + Bundle bundle = new Bundle(); + ArrayList actualInputs = new ArrayList<>(); + + for (RemoteInputParcel input : remoteInputs) { + Log.i("", "RemoteInput: " + input.getLabel()); + bundle.putCharSequence(input.getResultKey(), msg); + RemoteInput.Builder builder = new RemoteInput.Builder(input.getResultKey()); + builder.setLabel(input.getLabel()); + builder.setChoices(input.getChoices()); + builder.setAllowFreeFormInput(input.isAllowFreeFormInput()); + builder.addExtras(input.getExtras()); + actualInputs.add(builder.build()); + } + + RemoteInput[] inputs = actualInputs.toArray(new RemoteInput[actualInputs.size()]); + RemoteInput.addResultsToIntent(inputs, intent, bundle); + p.send(context, 0, intent); + } + + public ArrayList getRemoteInputs() { + return remoteInputs; + } + + public boolean isQuickReply() { + return isQuickReply; + } + + public String getText() { + return text; + } + + public PendingIntent getQuickReplyIntent() { + return isQuickReply ? p : null; + } + + public String getPackageName() { + return packageName; + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public Action createFromParcel(Parcel in) { + return new Action(in); + } + public Action[] newArray(int size) { + return new Action[size]; + } + }; + +} \ No newline at end of file diff --git a/src/android/NotificationCommands.java b/src/android/NotificationCommands.java new file mode 100644 index 0000000..d89518a --- /dev/null +++ b/src/android/NotificationCommands.java @@ -0,0 +1,326 @@ +package com.umycode.replyToNotification; + +import android.view.Gravity; +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaPlugin; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import android.util.Log; +import org.apache.cordova.PluginResult; +import android.service.notification.StatusBarNotification; +import android.os.Bundle; +import java.util.Set; + +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.app.RemoteInput; +import android.app.Notification; +import android.os.Build; +import android.text.TextUtils; +import com.umycode.replyToNotification.Action; +import android.content.Context; +import android.content.Intent; +import android.widget.Toast; +import android.os.Parcel; +import java.util.Arrays; +import android.util.Base64; +import java.util.List; +import java.util.LinkedList; +import java.io.PrintWriter; +import java.io.StringWriter; + +import android.app.PendingIntent; + + +public class NotificationCommands extends CordovaPlugin { + + protected static LinkedList> intent_stacks = new LinkedList>(); + protected static List intent_rooms = new LinkedList(); + + private static final String TAG = "NotificationCommands"; + + private static final String LISTEN = "listen"; + + private static final String[] REPLY_KEYWORDS = {"reply", "android.intent.extra.text","message_key"}; + private static final CharSequence REPLY_KEYWORD = "reply"; + private static final CharSequence INPUT_KEYWORD = "input"; + + // note that webView.isPaused() is not Xwalk compatible, so tracking it poor-man style + private boolean isPaused; + + private static CallbackContext listener; + private static Context current_context; + + + @Override + public boolean execute(String action, JSONArray args, final CallbackContext callbackContext) throws JSONException { + + if (!NotificationManagerCompat.getEnabledListenerPackages (this.cordova.getActivity().getApplicationContext()).contains(this.cordova.getActivity().getApplicationContext().getPackageName())) { + Toast.makeText(this.cordova.getActivity().getApplicationContext(), "Please Enable Notification Access", Toast.LENGTH_LONG).show(); + //service is not enabled try to enabled by calling... + this.cordova.getActivity().getApplicationContext().startActivity(new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + + callbackContext.error(TAG+". Need permissions to be granted."); + System.exit(0); + + return false; + } else { + Log.i(TAG, "Received action " + action); + current_context = this.cordova.getActivity().getApplicationContext(); + + if (LISTEN.equals(action)) { + setListener(callbackContext); + return true; + + }else if (action.equals("replytonotification")) { + + if(args.length() != 0){ + String room_name = args.getJSONObject(0).getString("room_name"); + String message = args.getJSONObject(0).getString("message"); + + // Do we have an existing intent for this + int indexOfRoomName = intent_rooms.indexOf(room_name); + if (indexOfRoomName > -1) { + try{ + replyToRoom(callbackContext,indexOfRoomName, message); + + PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } catch (Exception e){ + Log.e(TAG, "Unable to send notification "+ e); + + PluginResult result = new PluginResult(PluginResult.Status.OK, "Unable to send notification "+ e); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } + }else{ + Log.e(TAG, "Unable to send notification: Room has not been queued yet"); + + PluginResult result = new PluginResult(PluginResult.Status.OK, "Unable to send notification: Room has not been queued yet"); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } + } + return true; + } else { + callbackContext.error(TAG+". " + action + " is not a supported function."); + return false; + } + } + } + + @Override + public void onPause(boolean multitasking) { + this.isPaused = true; + } + + @Override + public void onResume(boolean multitasking) { + this.isPaused = false; + } + + public void setListener(CallbackContext callbackContext) { + Log.i("Notification", "Attaching callback context listener " + callbackContext); + listener = callbackContext; + + PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } + + public static void notifyListener(StatusBarNotification n){ + if (listener == null) { + Log.e(TAG, "Must define listener first. Call notificationListener.listen(success,error) first"); + return; + } + try { + + JSONObject json = parse(n); + + // Store the notification for later + Action reply_result = getQuickReplyAction(n.getNotification(),json.getString("package")); + + if(reply_result!=null && json.getString("package").equals("org.vom8x8.sipua") ){ + + // Store the intent + if (json.has("conversationTitle")) { + int indexOfRoomName = intent_rooms.indexOf(json.getString("conversationTitle")); + if (indexOfRoomName > -1) { + addIntentToList(indexOfRoomName,reply_result); + }else{ + intent_rooms.add(json.getString("conversationTitle")); + addIntentToList(-1,reply_result); + } + }else{ + int indexOfRoomName = intent_rooms.indexOf(json.getString("title")); + if (indexOfRoomName > -1) { + addIntentToList(indexOfRoomName,reply_result); + }else{ + intent_rooms.add(json.getString("title")); + addIntentToList(-1,reply_result); + } + } + } + + PluginResult result = new PluginResult(PluginResult.Status.OK, json); + + Log.i(TAG, "Sending notification to listener " + json.toString()); + result.setKeepCallback(true); + + listener.sendPluginResult(result); + } catch (Exception e){ + Log.e(TAG, "Unable to send notification "+ e); + listener.error(TAG+". Unable to send message: "+e.getMessage()); + } + } + + + private static JSONObject parse(StatusBarNotification n) throws JSONException{ + + JSONObject json = new JSONObject(); + + Bundle extras = n.getNotification().extras; + json.put("package", n.getPackageName()); + + Set keys = extras.keySet(); + /* Iterate over all keys of the bundle to give back all the information available */ + for (String key : keys) { + try { + String printKey = key; + /* If key has a prefix android., this will be removed. */ + if(printKey.indexOf("android.")==0 && printKey.length()>8){ + printKey = printKey.substring(8,key.length()); + } + // json.put(key, bundle.get(key)); see edit below + json.put(printKey, JSONObject.wrap(extras.get(key))); + } catch(JSONException e) { + Log.d(TAG,e.getMessage()); + } + } + + return json; + } + + private static String getExtraLines(Bundle extras, String extra){ + try { + CharSequence[] lines = extras.getCharSequenceArray(extra); + return lines[lines.length-1].toString(); + } catch( Exception e){ + Log.d(TAG, "Unable to get extra lines " + extra); + return ""; + } + } + private static String getExtra(Bundle extras, String extra){ + try { + return extras.get(extra).toString(); + } catch( Exception e){ + return ""; + } + } + + public static Action getQuickReplyAction(Notification n, String packageName) { + NotificationCompat.Action action = null; + if(Build.VERSION.SDK_INT >= 24) + action = getQuickReplyAction(n); + if(action == null) + action = getWearReplyAction(n); + if(action == null) + return null; + return new Action(action, packageName, true); + } + + private static NotificationCompat.Action getQuickReplyAction(Notification n) { + for(int i = 0; i < NotificationCompat.getActionCount(n); i++) { + NotificationCompat.Action action = NotificationCompat.getAction(n, i); + if(action.getRemoteInputs() != null) { + for (int x = 0; x < action.getRemoteInputs().length; x++) { + RemoteInput remoteInput = action.getRemoteInputs()[x]; + if (isKnownReplyKey(remoteInput.getResultKey())) + return action; + } + } + } + return null; + } + + private static NotificationCompat.Action getWearReplyAction(Notification n) { + NotificationCompat.WearableExtender wearableExtender = new NotificationCompat.WearableExtender(n); + for (NotificationCompat.Action action : wearableExtender.getActions()) { + if(action.getRemoteInputs() != null) { + for (int x = 0; x < action.getRemoteInputs().length; x++) { + RemoteInput remoteInput = action.getRemoteInputs()[x]; + if (isKnownReplyKey(remoteInput.getResultKey())) + return action; + else if (remoteInput.getResultKey().toLowerCase().contains(INPUT_KEYWORD)) + return action; + } + } + } + return null; + } + + private static boolean isKnownReplyKey(String resultKey) { + if(TextUtils.isEmpty(resultKey)) + return false; + + resultKey = resultKey.toLowerCase(); + for(String keyword : REPLY_KEYWORDS) + if(resultKey.contains(keyword)) + return true; + + return false; + } + + private static void addIntentToList(int indexOfRoomName, Action reply_result){ + if(indexOfRoomName >= 0){ + // we are adding to an existing stack + intent_stacks.get(indexOfRoomName).add(reply_result); + + // is it too long now? + if(intent_stacks.get(indexOfRoomName).size() > 10){ + Log.i(TAG, "Removing intent from stack "); + intent_stacks.get(indexOfRoomName).removeFirst(); + } + }else{ + intent_stacks.add(new LinkedList<>()); + intent_stacks.get(intent_stacks.size()-1).add(reply_result); + + // is it too long now? + if(intent_stacks.get(intent_stacks.size()-1).size() > 10){ + Log.i(TAG, "Removing intent from stack "); + intent_stacks.get(intent_stacks.size()-1).removeFirst(); + } + } + } + + public static void replyToRoom(CallbackContext callbackContext,int indexOfRoomName, String message) throws PendingIntent.CanceledException{ + try{ + if(intent_stacks.get(indexOfRoomName).size()>0){ + Action reply_result = intent_stacks.get(indexOfRoomName).get(0); + reply_result.sendReply(current_context, message); + }else{ + PluginResult result = new PluginResult(PluginResult.Status.OK, "Unable to send notification. No stored intents."); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } + } catch (Exception e){ + Log.e(TAG, "Unable to send notification "+ e); + + // Can we try the next one? + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String exceptionAsString = sw.toString(); + if(exceptionAsString.contains("CanceledException")){ + Log.i(TAG, "Removing intent from stack "); + intent_stacks.get(indexOfRoomName).removeFirst(); + replyToRoom(callbackContext,indexOfRoomName, message); + }else{ + PluginResult result = new PluginResult(PluginResult.Status.OK, "Unable to send notification "+ e); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } + } + } +} \ No newline at end of file diff --git a/src/android/NotificationService.java b/src/android/NotificationService.java new file mode 100644 index 0000000..2b72b26 --- /dev/null +++ b/src/android/NotificationService.java @@ -0,0 +1,87 @@ +package com.umycode.replyToNotification; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.service.notification.NotificationListenerService; +import android.service.notification.StatusBarNotification; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +public class NotificationService extends NotificationListenerService { + //http://developer.android.com/reference/android/service/notification/NotificationListenerService.html + + private static final String TAG = NotificationService.class.getSimpleName(); + + //TODO store this in config + private static final String IGNORE_PKG = "snapdragon,com.google.android.googlequicksearchbox"; + private static int notificationId = 1; + + private static List notifications ; + public static boolean enabled = false; + private static Context context = null; + + @Override + public void onCreate() { + super.onCreate(); + Log.i(TAG, "onCreate"); + enabled = true; + + notifications = new ArrayList(); + context = this; + } + @Override + public void onDestroy() { + Log.i(TAG, "onDestroy"); + enabled = false; + } + @Override + public void onNotificationPosted(StatusBarNotification sbn) { + //Do not send notifications from this app (can cause an infinite loop) + Log.d(TAG, "notification package name " + sbn.getPackageName()); + + String pk = sbn.getPackageName(); + + if (pk.equals("android") || ignorePkg(pk) || sbn.isOngoing()) Log.d(TAG, "Ignore notification from pkg " + pk); + else { + NotificationCommands.notifyListener(sbn); + addNotification(sbn); + } + } + private boolean ignorePkg(String pk){ + for(String s: IGNORE_PKG.split(",")) if (pk.contains(s)) return true; + return false; + } + + @Override + public void onNotificationRemoved(StatusBarNotification sbn) { + //debugNotification(sbn); + } + + private void addNotification(StatusBarNotification msg){ + notifications.add(msg); + } + + public static void removeAll(){ + try { + for (StatusBarNotification n : notifications) remove(n); + notifications.clear(); + } catch (Exception e){ + Log.e(TAG, "Unable to remove notifications",e); + } + } + private static void remove(StatusBarNotification n){ + String ns = Context.NOTIFICATION_SERVICE; + NotificationManager nMgr = (NotificationManager) context.getApplicationContext().getSystemService(ns); + + int id = n.getId(); + String tag = n.getTag(); + Log.i("Cancelling notification ", tag + ", " + id); + nMgr.cancel(tag, id); + } +} diff --git a/src/android/RemoteInputParcel.java b/src/android/RemoteInputParcel.java new file mode 100644 index 0000000..2519151 --- /dev/null +++ b/src/android/RemoteInputParcel.java @@ -0,0 +1,88 @@ +package com.umycode.replyToNotification; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.app.RemoteInput; + +/** + * Created by JJ on 05/08/15. + */ +public class RemoteInputParcel implements Parcelable { + + private String label; + private String resultKey; + private String[] choices = new String[0]; + private boolean allowFreeFormInput; + private Bundle extras; + + + public RemoteInputParcel(RemoteInput input) { + label = input.getLabel().toString(); + resultKey = input.getResultKey(); + charSequenceToStringArray(input.getChoices()); + allowFreeFormInput = input.getAllowFreeFormInput(); + extras = input.getExtras(); + } + + public RemoteInputParcel(Parcel in) { + label = in.readString(); + resultKey = in.readString(); + choices = in.createStringArray(); + allowFreeFormInput = in.readByte() != 0; + extras = in.readParcelable(Bundle.class.getClassLoader()); + } + + public void charSequenceToStringArray(CharSequence[] charSequence) { + if(charSequence != null) { + int size = charSequence.length; + choices = new String[charSequence.length]; + for (int i = 0; i < size; i++) + choices[i] = charSequence[i].toString(); + } + } + + public String getResultKey() { + return resultKey; + } + + public String getLabel() { + return label; + } + + public CharSequence[] getChoices() { + return choices; + } + + public boolean isAllowFreeFormInput() { + return allowFreeFormInput; + } + + public Bundle getExtras() { + return extras; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(label); + dest.writeString(resultKey); + dest.writeStringArray(choices); + dest.writeByte((byte) (allowFreeFormInput ? 1 : 0)); + dest.writeParcelable(extras, flags); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public RemoteInputParcel createFromParcel(Parcel in) { + return new RemoteInputParcel(in); + } + public RemoteInputParcel[] newArray(int size) { + return new RemoteInputParcel[size]; + } + }; + +} \ No newline at end of file diff --git a/www/reply-to-notification.js b/www/reply-to-notification.js new file mode 100644 index 0000000..bc376d8 --- /dev/null +++ b/www/reply-to-notification.js @@ -0,0 +1,26 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +"use strict"; + +module.exports = { + + // value must be an ArrayBuffer + listen: function (success, failure) { + console.log("Calling cordova listen method"); + cordova.exec(success, failure, 'ReplyToNotification', 'listen', []); + }, + replytonotification: function (arg0, success, error){ + console.log("Calling replytonotification method"); + cordova.exec(success, error, 'ReplyToNotification', 'replytonotification', [arg0]); + } +};