diff --git a/.gitignore b/.gitignore index 0235c648c..5d1edaf6c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /src/windows/**/bin /src/windows/**/obj .idea -src/cordova-plugin-local-notifications.iml \ No newline at end of file +src/cordova-plugin-local-notifications.iml +.DS_Store diff --git a/plugin.xml b/plugin.xml index 8dd76a36a..e975b8ffe 100644 --- a/plugin.xml +++ b/plugin.xml @@ -93,6 +93,11 @@ + + + + + @@ -100,7 +105,7 @@ - + - + android:exported="false" + android:theme="@style/Theme.AppCompat.NoActionBar"/> - + + + + + + + + @@ -207,6 +224,10 @@ src="src/android/notification/util/AssetUtil.java" target-dir="src/de/appplant/cordova/plugin/notification/util" /> + + @@ -219,6 +240,10 @@ src="src/android/notification/Notification.java" target-dir="src/de/appplant/cordova/plugin/notification" /> + + diff --git a/src/android/ClearReceiver.java b/src/android/ClearReceiver.java index c4e771c91..55ed849cc 100644 --- a/src/android/ClearReceiver.java +++ b/src/android/ClearReceiver.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License diff --git a/src/android/ClickReceiver.java b/src/android/ClickReceiver.java index 404fd0633..eae916157 100644 --- a/src/android/ClickReceiver.java +++ b/src/android/ClickReceiver.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -22,13 +23,14 @@ package de.appplant.cordova.plugin.localnotification; import android.os.Bundle; -import android.support.v4.app.RemoteInput; +import androidx.core.app.RemoteInput; import org.json.JSONException; import org.json.JSONObject; import de.appplant.cordova.plugin.notification.Notification; import de.appplant.cordova.plugin.notification.receiver.AbstractClickReceiver; +import de.appplant.cordova.plugin.notification.util.LaunchUtils; import static de.appplant.cordova.plugin.localnotification.LocalNotification.fireEvent; import static de.appplant.cordova.plugin.notification.Options.EXTRA_LAUNCH; @@ -95,7 +97,7 @@ private void launchAppIf() { if (!doLaunch) return; - launchApp(); + LaunchUtils.launchApp(getApplicationContext()); } /** diff --git a/src/android/LocalNotification.java b/src/android/LocalNotification.java index e02f477f7..27e399413 100644 --- a/src/android/LocalNotification.java +++ b/src/android/LocalNotification.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -26,9 +27,26 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.app.KeyguardManager; +import android.app.NotificationManager; +import android.content.ActivityNotFoundException; import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PermissionInfo; +import android.net.Uri; +import android.os.Bundle; +import android.os.PowerManager; +import android.provider.Settings; import android.util.Pair; import android.view.View; +// Notification permission + +import android.app.NotificationManager; +import android.app.NotificationChannel; +import android.app.PendingIntent; +import androidx.core.app.NotificationCompat; +import android.content.Intent; import org.apache.cordova.CallbackContext; import org.apache.cordova.CordovaInterface; @@ -43,22 +61,30 @@ import java.util.ArrayList; import java.util.List; +import javax.security.auth.callback.Callback; + import de.appplant.cordova.plugin.notification.Manager; import de.appplant.cordova.plugin.notification.Notification; import de.appplant.cordova.plugin.notification.Options; import de.appplant.cordova.plugin.notification.Request; import de.appplant.cordova.plugin.notification.action.ActionGroup; +import static android.Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS; +import static android.content.Context.POWER_SERVICE; +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.M; import static de.appplant.cordova.plugin.notification.Notification.Type.SCHEDULED; import static de.appplant.cordova.plugin.notification.Notification.Type.TRIGGERED; +// import com.getcapacitor.CapacitorWebView; + /** * This plugin utilizes the Android AlarmManager in combination with local * notifications. When a local notification is scheduled the alarm manager takes * care of firing the event. When the event is processed, a notification is put * in the Android notification center and status bar. */ -@SuppressWarnings({"Convert2Diamond", "Convert2Lambda"}) +@SuppressWarnings({ "Convert2Diamond", "Convert2Lambda" }) public class LocalNotification extends CordovaPlugin { // Reference to the web view for static access @@ -73,13 +99,19 @@ public class LocalNotification extends CordovaPlugin { // Launch details private static Pair launchDetails; + private static int REQUEST_PERMISSIONS_CALL = 10; + + private static int REQUEST_IGNORE_BATTERY_CALL = 20; + + private CallbackContext callbackContext; + /** - * Called after plugin construction and fields have been initialized. - * Prefer to use pluginInitialize instead since there is no value in - * having parameters on the initialize() function. + * Called after plugin construction and fields have been initialized. Prefer to + * use pluginInitialize instead since there is no value in having parameters on + * the initialize() function. */ @Override - public void initialize (CordovaInterface cordova, CordovaWebView webView) { + public void initialize(CordovaInterface cordova, CordovaWebView webView) { LocalNotification.webView = new WeakReference(webView); } @@ -89,7 +121,7 @@ public void initialize (CordovaInterface cordova, CordovaWebView webView) { * @param multitasking Flag indicating if multitasking is turned on for app. */ @Override - public void onResume (boolean multitasking) { + public void onResume(boolean multitasking) { super.onResume(multitasking); deviceready(); } @@ -105,23 +137,20 @@ public void onDestroy() { /** * Executes the request. * - * This method is called from the WebView thread. To do a non-trivial - * amount of work, use: - * cordova.getThreadPool().execute(runnable); + * This method is called from the WebView thread. To do a non-trivial amount of + * work, use: cordova.getThreadPool().execute(runnable); * - * To run on the UI thread, use: - * cordova.getActivity().runOnUiThread(runnable); + * To run on the UI thread, use: cordova.getActivity().runOnUiThread(runnable); * * @param action The action to execute. * @param args The exec() arguments in JSON form. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. * * @return Whether the action was valid. */ @Override - public boolean execute (final String action, final JSONArray args, - final CallbackContext command) throws JSONException { + public boolean execute(final String action, final JSONArray args, final CallbackContext command) + throws JSONException { if (action.equals("launch")) { launch(command); @@ -132,45 +161,42 @@ public boolean execute (final String action, final JSONArray args, public void run() { if (action.equals("ready")) { deviceready(); - } else - if (action.equals("check")) { + } else if (action.equals("check")) { check(command); - } else - if (action.equals("request")) { + } else if (action.equals("request")) { request(command); - } else - if (action.equals("actions")) { + } else if (action.equals("actions")) { actions(args, command); - } else - if (action.equals("schedule")) { + } else if (action.equals("schedule")) { schedule(args, command); - } else - if (action.equals("update")) { + } else if (action.equals("update")) { update(args, command); - } else - if (action.equals("cancel")) { + } else if (action.equals("cancel")) { cancel(args, command); - } else - if (action.equals("cancelAll")) { + } else if (action.equals("cancelAll")) { cancelAll(command); - } else - if (action.equals("clear")) { + } else if (action.equals("clear")) { clear(args, command); - } else - if (action.equals("clearAll")) { + } else if (action.equals("clearAll")) { clearAll(command); - } else - if (action.equals("type")) { + } else if (action.equals("type")) { type(args, command); - } else - if (action.equals("ids")) { + } else if (action.equals("ids")) { ids(args, command); - } else - if (action.equals("notification")) { + } else if (action.equals("notification")) { notification(args, command); - } else - if (action.equals("notifications")) { + } else if (action.equals("notifications")) { notifications(args, command); + } else if (action.equals("hasDoNotDisturbPermissions")) { + hasDoNotDisturbPermissions(command); + } else if (action.equals("requestDoNotDisturbPermissions")) { + requestDoNotDisturbPermissions(command); + } else if (action.equals("isIgnoringBatteryOptimizations")) { + isIgnoringBatteryOptimizations(command); + } else if (action.equals("requestIgnoreBatteryOptimizations")) { + requestIgnoreBatteryOptimizations(command); + } else if (action.equals("dummyNotifications")) { + dummyNotifications(command); } } }); @@ -179,11 +205,189 @@ public void run() { } /** - * Set launchDetails object. + * required for android 13 to get the runtime notification permissions + * * * @param command The callback context used when calling back into * JavaScript. */ + private void dummyNotifications(CallbackContext command) { + + fireEvent("dummyNotifications"); + NotificationManager mNotificationManager; + NotificationCompat.Builder mBuilder; + String NOTIFICATION_CHANNEL_ID = "10004457"; + String notificationMsg = "Test"; + String notificationTitle = "Mdd"; + Context context = cordova.getActivity().getApplicationContext(); + + Intent intentToLaunch = new Intent(context, TriggerReceiver.class); + intentToLaunch.putExtra("Callfrom", "reminders"); + + final PendingIntent resultPendingIntent = PendingIntent.getActivity(context, + 0, intentToLaunch, PendingIntent.FLAG_IMMUTABLE); + + mBuilder = new NotificationCompat.Builder(context,NOTIFICATION_CHANNEL_ID); + + mBuilder.setContentIntent(resultPendingIntent); + + mNotificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) + { + int importance = NotificationManager.IMPORTANCE_HIGH; + NotificationChannel notificationChannel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, "NOTIFICATION_CHANNEL_NAME", importance); + assert mNotificationManager != null; + mBuilder.setChannelId(NOTIFICATION_CHANNEL_ID); + mNotificationManager.createNotificationChannel(notificationChannel); + } + + command.success(); + } + + /** + * Determine if do not disturb permissions have been granted + * + * @return true if we still need to acquire do not disturb permissions. + */ + private boolean needsDoNotDisturbPermissions() { + Context mContext = this.cordova.getActivity().getApplicationContext(); + + NotificationManager mNotificationManager = (NotificationManager) mContext + .getSystemService(Context.NOTIFICATION_SERVICE); + + return SDK_INT >= M && !mNotificationManager.isNotificationPolicyAccessGranted(); + } + + /** + * Determine if we have do not disturb permissions. + * + * @param command callback context. Returns with true if the we have + * permissions, false if we do not. + */ + private void hasDoNotDisturbPermissions(CallbackContext command) { + success(command, !needsDoNotDisturbPermissions()); + } + + /** + * Launch an activity to request do not disturb permissions + * + * @param command callback context. Returns with results of + * hasDoNotDisturbPermissions after the activity is closed. + */ + private void requestDoNotDisturbPermissions(CallbackContext command) { + if (needsDoNotDisturbPermissions()) { + this.callbackContext = command; + + PluginResult pluginResult = new PluginResult(PluginResult.Status.NO_RESULT); + pluginResult.setKeepCallback(true); // Keep callback + command.sendPluginResult(pluginResult); + + Intent intent = new Intent(android.provider.Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS); + + cordova.startActivityForResult(this, intent, REQUEST_PERMISSIONS_CALL); + return; + } + success(command, true); + } + + /** + * Determine if do not battery optimization permissions have been granted + * + * @return true if we are succcessfully ignoring battery permissions. + */ + private boolean ignoresBatteryOptimizations() { + Context mContext = this.cordova.getActivity().getApplicationContext(); + PowerManager pm = (PowerManager) mContext.getSystemService(POWER_SERVICE); + + return SDK_INT <= M || pm.isIgnoringBatteryOptimizations(mContext.getPackageName()); + } + + /** + * Determine if we have do not disturb permissions. + * + * @param command callback context. Returns with true if the we have + * permissions, false if we do not. + */ + private void isIgnoringBatteryOptimizations(CallbackContext command) { + success(command, ignoresBatteryOptimizations()); + } + + /** + * Launch an activity to request do not disturb permissions + * + * @param command callback context. Returns with results of + * hasDoNotDisturbPermissions after the activity is closed. + */ + private void requestIgnoreBatteryOptimizations(CallbackContext command) { + if (!ignoresBatteryOptimizations()) { + this.callbackContext = command; + + PluginResult pluginResult = new PluginResult(PluginResult.Status.NO_RESULT); + pluginResult.setKeepCallback(true); // Keep callback + command.sendPluginResult(pluginResult); + + String packageName = this.cordova.getContext().getPackageName(); + String action = Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS; + + // use the generic intent if we don't have access to request ignore permissions + // directly + // User can add "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" to the manifest, but + // risks having the app banned. + try { + PackageManager packageManager = this.cordova.getContext().getPackageManager(); + PackageInfo pi = packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS); + + for (int i = 0; i < pi.requestedPermissions.length; ++i) { + if (pi.requestedPermissions[i].equals(REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)) { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS; + } + } + } catch (PackageManager.NameNotFoundException e) { + // leave action as default if package not found + } + + try { + Intent intent = new Intent(action); + + intent.setData(Uri.parse("package:" + packageName)); + + cordova.startActivityForResult(this, intent, REQUEST_IGNORE_BATTERY_CALL); + } catch (ActivityNotFoundException e) { + // could not find the generic ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS + // and did not have access to launch REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + // Fallback to just figuring out if battery optimizations are removed (probably + // not) + // since we can't ask the user to set it, because we can't launch an activity. + isIgnoringBatteryOptimizations(command); + this.callbackContext = null; + } + + return; + } + success(command, true); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_PERMISSIONS_CALL && this.callbackContext != null) { + hasDoNotDisturbPermissions(this.callbackContext); + + // clean up callback context. + this.callbackContext = null; + } else if (requestCode == REQUEST_IGNORE_BATTERY_CALL && this.callbackContext != null) { + isIgnoringBatteryOptimizations(this.callbackContext); + + this.callbackContext = null; + } + super.onActivityResult(requestCode, resultCode, data); + } + + /** + * Set launchDetails object. + * + * @param command The callback context used when calling back into JavaScript. + */ @SuppressLint("DefaultLocale") private void launch(CallbackContext command) { if (launchDetails == null) @@ -206,21 +410,19 @@ private void launch(CallbackContext command) { /** * Ask if user has enabled permission for local notifications. * - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void check (CallbackContext command) { - boolean allowed = getNotMgr().getNotCompMgr().areNotificationsEnabled(); + private void check(CallbackContext command) { + boolean allowed = getNotMgr().hasPermission(); success(command, allowed); } /** * Request permission for local notifications. * - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void request (CallbackContext command) { + private void request(CallbackContext command) { check(command); } @@ -228,29 +430,28 @@ private void request (CallbackContext command) { * Register action group. * * @param args The exec() arguments in JSON form. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void actions (JSONArray args, CallbackContext command) { - int task = args.optInt(0); - String id = args.optString(1); - JSONArray list = args.optJSONArray(2); + private void actions(JSONArray args, CallbackContext command) { + int task = args.optInt(0); + String id = args.optString(1); + JSONArray list = args.optJSONArray(2); Context context = cordova.getActivity(); switch (task) { - case 0: - ActionGroup group = ActionGroup.parse(context, id, list); - ActionGroup.register(group); - command.success(); - break; - case 1: - ActionGroup.unregister(id); - command.success(); - break; - case 2: - boolean found = ActionGroup.isRegistered(id); - success(command, found); - break; + case 0: + ActionGroup group = ActionGroup.parse(context, id, list); + ActionGroup.register(group); + command.success(); + break; + case 1: + ActionGroup.unregister(id); + command.success(); + break; + case 2: + boolean found = ActionGroup.isRegistered(id); + success(command, found); + break; } } @@ -258,16 +459,15 @@ private void actions (JSONArray args, CallbackContext command) { * Schedule multiple local notifications. * * @param toasts The notifications to schedule. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void schedule (JSONArray toasts, CallbackContext command) { + private void schedule(JSONArray toasts, CallbackContext command) { Manager mgr = getNotMgr(); for (int i = 0; i < toasts.length(); i++) { - JSONObject dict = toasts.optJSONObject(i); - Options options = new Options(dict); - Request request = new Request(options); + JSONObject dict = toasts.optJSONObject(i); + Options options = new Options(dict); + Request request = new Request(options); Notification toast = mgr.schedule(request, TriggerReceiver.class); if (toast != null) { @@ -282,15 +482,14 @@ private void schedule (JSONArray toasts, CallbackContext command) { * Update multiple local notifications. * * @param updates Notification properties including their IDs. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void update (JSONArray updates, CallbackContext command) { + private void update(JSONArray updates, CallbackContext command) { Manager mgr = getNotMgr(); for (int i = 0; i < updates.length(); i++) { - JSONObject update = updates.optJSONObject(i); - int id = update.optInt("id", 0); + JSONObject update = updates.optJSONObject(i); + int id = update.optInt("id", 0); Notification toast = mgr.update(id, update, TriggerReceiver.class); if (toast == null) @@ -306,14 +505,13 @@ private void update (JSONArray updates, CallbackContext command) { * Cancel multiple local notifications. * * @param ids Set of local notification IDs. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void cancel (JSONArray ids, CallbackContext command) { + private void cancel(JSONArray ids, CallbackContext command) { Manager mgr = getNotMgr(); for (int i = 0; i < ids.length(); i++) { - int id = ids.optInt(i, 0); + int id = ids.optInt(i, 0); Notification toast = mgr.cancel(id); if (toast == null) @@ -328,8 +526,7 @@ private void cancel (JSONArray ids, CallbackContext command) { /** * Cancel all scheduled notifications. * - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ private void cancelAll(CallbackContext command) { getNotMgr().cancelAll(); @@ -341,14 +538,13 @@ private void cancelAll(CallbackContext command) { * Clear multiple local notifications without canceling them. * * @param ids Set of local notification IDs. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ private void clear(JSONArray ids, CallbackContext command) { Manager mgr = getNotMgr(); for (int i = 0; i < ids.length(); i++) { - int id = ids.optInt(i, 0); + int id = ids.optInt(i, 0); Notification toast = mgr.clear(id); if (toast == null) @@ -363,8 +559,7 @@ private void clear(JSONArray ids, CallbackContext command) { /** * Clear all triggered notifications without canceling them. * - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ private void clearAll(CallbackContext command) { getNotMgr().clearAll(); @@ -376,11 +571,10 @@ private void clearAll(CallbackContext command) { * Get the type of the notification (unknown, scheduled, triggered). * * @param args The exec() arguments in JSON form. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void type (JSONArray args, CallbackContext command) { - int id = args.optInt(0); + private void type(JSONArray args, CallbackContext command) { + int id = args.optInt(0); Notification toast = getNotMgr().get(id); if (toast == null) { @@ -389,15 +583,15 @@ private void type (JSONArray args, CallbackContext command) { } switch (toast.getType()) { - case SCHEDULED: - command.success("scheduled"); - break; - case TRIGGERED: - command.success("triggered"); - break; - default: - command.success("unknown"); - break; + case SCHEDULED: + command.success("scheduled"); + break; + case TRIGGERED: + command.success("triggered"); + break; + default: + command.success("unknown"); + break; } } @@ -405,27 +599,26 @@ private void type (JSONArray args, CallbackContext command) { * Set of IDs from all existent notifications. * * @param args The exec() arguments in JSON form. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void ids (JSONArray args, CallbackContext command) { - int type = args.optInt(0); + private void ids(JSONArray args, CallbackContext command) { + int type = args.optInt(0); Manager mgr = getNotMgr(); List ids; switch (type) { - case 0: - ids = mgr.getIds(); - break; - case 1: - ids = mgr.getIdsByType(SCHEDULED); - break; - case 2: - ids = mgr.getIdsByType(TRIGGERED); - break; - default: - ids = new ArrayList(0); - break; + case 0: + ids = mgr.getIds(); + break; + case 1: + ids = mgr.getIdsByType(SCHEDULED); + break; + case 2: + ids = mgr.getIdsByType(TRIGGERED); + break; + default: + ids = new ArrayList(0); + break; } command.success(new JSONArray(ids)); @@ -435,11 +628,10 @@ private void ids (JSONArray args, CallbackContext command) { * Options from local notification. * * @param args The exec() arguments in JSON form. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void notification (JSONArray args, CallbackContext command) { - int id = args.optInt(0); + private void notification(JSONArray args, CallbackContext command) { + int id = args.optInt(0); Options opts = getNotMgr().getOptions(id); if (opts != null) { @@ -453,31 +645,30 @@ private void notification (JSONArray args, CallbackContext command) { * Set of options from local notification. * * @param args The exec() arguments in JSON form. - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. */ - private void notifications (JSONArray args, CallbackContext command) { - int type = args.optInt(0); + private void notifications(JSONArray args, CallbackContext command) { + int type = args.optInt(0); JSONArray ids = args.optJSONArray(1); - Manager mgr = getNotMgr(); + Manager mgr = getNotMgr(); List options; switch (type) { - case 0: - options = mgr.getOptions(); - break; - case 1: - options = mgr.getOptionsByType(SCHEDULED); - break; - case 2: - options = mgr.getOptionsByType(TRIGGERED); - break; - case 3: - options = mgr.getOptionsById(toList(ids)); - break; - default: - options = new ArrayList(0); - break; + case 0: + options = mgr.getOptions(); + break; + case 1: + options = mgr.getOptionsByType(SCHEDULED); + break; + case 2: + options = mgr.getOptionsByType(TRIGGERED); + break; + case 3: + options = mgr.getOptionsById(toList(ids)); + break; + default: + options = new ArrayList(0); + break; } command.success(new JSONArray(options)); @@ -499,8 +690,7 @@ private static synchronized void deviceready() { /** * Invoke success callback with a single boolean argument. * - * @param command The callback context used when calling back into - * JavaScript. + * @param command The callback context used when calling back into JavaScript. * @param arg The single argument to pass through. */ private void success(CallbackContext command, boolean arg) { @@ -513,7 +703,7 @@ private void success(CallbackContext command, boolean arg) { * * @param event The event name. */ - private void fireEvent (String event) { + private void fireEvent(String event) { fireEvent(event, null, new JSONObject()); } @@ -523,7 +713,7 @@ private void fireEvent (String event) { * @param event The event name. * @param notification Optional notification to pass with. */ - static void fireEvent (String event, Notification notification) { + static void fireEvent(String event, Notification notification) { fireEvent(event, notification, new JSONObject()); } @@ -534,7 +724,7 @@ static void fireEvent (String event, Notification notification) { * @param toast Optional notification to pass with. * @param data Event object with additional data. */ - static void fireEvent (String event, Notification toast, JSONObject data) { + static void fireEvent(String event, Notification toast, JSONObject data) { String params, js; try { @@ -555,8 +745,7 @@ static void fireEvent (String event, Notification toast, JSONObject data) { params = data.toString(); } - js = "cordova.plugins.notification.local.fireEvent(" + - "\"" + event + "\"," + params + ")"; + js = "cordova.plugins.notification.local.fireEvent(" + "\"" + event + "\"," + params + ")"; if (launchDetails == null && !deviceready && toast != null) { launchDetails = new Pair(toast.getId(), event); @@ -565,7 +754,7 @@ static void fireEvent (String event, Notification toast, JSONObject data) { sendJavascript(js); } - /** + /** * Use this instead of deprecated sendJavascript * * @param js JS code snippet as string. @@ -577,29 +766,58 @@ private static synchronized void sendJavascript(final String js) { return; } + if (!deviceready || webView == null) { + eventQueue.add(js); + return; + } + final CordovaWebView view = webView.get(); - ((Activity)(view.getContext())).runOnUiThread(new Runnable() { + ((Activity) (view.getContext())).runOnUiThread(new Runnable() { public void run() { view.loadUrl("javascript:" + js); + View engineView = view.getEngine() != null ? view.getEngine().getView() : view.getView(); + + if (!isInForeground()) { + engineView.dispatchWindowVisibilityChanged(View.VISIBLE); + } } }); + + // Capacitor FIX: If the app is in the background, we need to make sure the notification is shown. + // if (webView.get() == null) { + // return; + // } + + // try { + // final CapacitorWebView capWebView = (CapacitorWebView) webView.get().getView(); + // if (capWebView == null) { + // return; + // } + + // capWebView.post(new Runnable() { + // public void run() { + // capWebView.loadUrl("javascript:" + js); + // } + // }); + // } catch (Exception e) { + // e.printStackTrace(); + // } } /** * If the app is running in foreground. */ - private static boolean isInForeground() { + public static boolean isInForeground() { if (!deviceready || webView == null) return false; CordovaWebView view = webView.get(); - KeyguardManager km = (KeyguardManager) view.getContext() - .getSystemService(Context.KEYGUARD_SERVICE); + KeyguardManager km = (KeyguardManager) view.getContext().getSystemService(Context.KEYGUARD_SERVICE); - //noinspection SimplifiableIfStatement + // noinspection SimplifiableIfStatement if (km != null && km.isKeyguardLocked()) return false; @@ -618,7 +836,7 @@ static boolean isAppRunning() { * * @param ary Array of integers. */ - private List toList (JSONArray ary) { + private List toList(JSONArray ary) { List list = new ArrayList(); for (int i = 0; i < ary.length(); i++) { diff --git a/src/android/RestoreReceiver.java b/src/android/RestoreReceiver.java index 0d51fd22c..82cb7add8 100644 --- a/src/android/RestoreReceiver.java +++ b/src/android/RestoreReceiver.java @@ -34,13 +34,16 @@ import de.appplant.cordova.plugin.notification.Request; import de.appplant.cordova.plugin.notification.receiver.AbstractRestoreReceiver; +import static de.appplant.cordova.plugin.localnotification.LocalNotification.fireEvent; +import static de.appplant.cordova.plugin.localnotification.LocalNotification.isAppRunning; +import static de.appplant.cordova.plugin.localnotification.LocalNotification.isInForeground; + /** * This class is triggered upon reboot of the device. It needs to re-register * the alarms with the AlarmManager since these alarms are lost in case of * reboot. */ public class RestoreReceiver extends AbstractRestoreReceiver { - /** * Called when a local notification need to be restored. * @@ -53,17 +56,29 @@ public void onRestore (Request request, Notification toast) { boolean after = date != null && date.after(new Date()); if (!after && toast.isHighPrio()) { - toast.show(); + performNotification(toast); } else { - toast.clear(); + // reschedule if we aren't firing here. + // If we do fire, performNotification takes care of + // next schedule. + + Context ctx = toast.getContext(); + Manager mgr = Manager.getInstance(ctx); + + if (after || toast.isRepeating()) { + mgr.schedule(request, TriggerReceiver.class); + } } + } - Context ctx = toast.getContext(); - Manager mgr = Manager.getInstance(ctx); + @Override + public void dispatchAppEvent(String key, Notification notification) { + fireEvent(key, notification); + } - if (after || toast.isRepeating()) { - mgr.schedule(request, TriggerReceiver.class); - } + @Override + public boolean checkAppRunning() { + return isAppRunning(); } /** diff --git a/src/android/TriggerReceiver.java b/src/android/TriggerReceiver.java index f11a5626a..835ad38ce 100644 --- a/src/android/TriggerReceiver.java +++ b/src/android/TriggerReceiver.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -33,14 +34,19 @@ import de.appplant.cordova.plugin.notification.Options; import de.appplant.cordova.plugin.notification.Request; import de.appplant.cordova.plugin.notification.receiver.AbstractTriggerReceiver; +import de.appplant.cordova.plugin.notification.util.LaunchUtils; import static android.content.Context.POWER_SERVICE; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.O; import static de.appplant.cordova.plugin.localnotification.LocalNotification.fireEvent; import static de.appplant.cordova.plugin.localnotification.LocalNotification.isAppRunning; +import static de.appplant.cordova.plugin.localnotification.LocalNotification.isInForeground; import static java.util.Calendar.MINUTE; +import static android.os.Build.VERSION_CODES.P; + /** * The alarm receiver is triggered when a scheduled alarm is fired. This class * reads the information in the intent and displays this information in the @@ -57,64 +63,18 @@ public class TriggerReceiver extends AbstractTriggerReceiver { * @param bundle The bundled extras. */ @Override - public void onTrigger (Notification notification, Bundle bundle) { - boolean isUpdate = bundle.getBoolean(Notification.EXTRA_UPDATE, false); - Context context = notification.getContext(); - Options options = notification.getOptions(); - Manager manager = Manager.getInstance(context); - int badge = options.getBadgeNumber(); - - if (badge > 0) { - manager.setBadge(badge); - } - - if (options.shallWakeUp()) { - wakeUp(context); - } - - manager.createChannel(options); - - notification.show(); - - if (!isUpdate && isAppRunning()) { - fireEvent("trigger", notification); - } - - if (!options.isInfiniteTrigger()) - return; - - Calendar cal = Calendar.getInstance(); - cal.add(MINUTE, 1); - Request req = new Request(options, cal.getTime()); - - manager.schedule(req, this.getClass()); + public void onTrigger(Notification notification, Bundle bundle) { + performNotification(notification); } - /** - * Wakeup the device. - * - * @param context The application context. - */ - private void wakeUp (Context context) { - PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE); - - if (pm == null) - return; - - int level = PowerManager.SCREEN_DIM_WAKE_LOCK - | PowerManager.ACQUIRE_CAUSES_WAKEUP; - - PowerManager.WakeLock wakeLock = pm.newWakeLock( - level, "LocalNotification"); - - wakeLock.setReferenceCounted(false); - wakeLock.acquire(1000); + @Override + public void dispatchAppEvent(String key, Notification notification) { + fireEvent(key, notification); + } - if (SDK_INT >= LOLLIPOP) { - wakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); - } else { - wakeLock.release(); - } + @Override + public boolean checkAppRunning() { + return isAppRunning(); } /** @@ -124,12 +84,12 @@ private void wakeUp (Context context) { * @param bundle The bundled extras. */ @Override - public Notification buildNotification (Builder builder, Bundle bundle) { - return builder - .setClickActivity(ClickReceiver.class) - .setClearReceiver(ClearReceiver.class) - .setExtras(bundle) - .build(); + public Notification buildNotification(Builder builder, Bundle bundle) { + return builder + .setClickActivity(ClickReceiver.class) + .setClearReceiver(ClearReceiver.class) + .setExtras(bundle) + .build(); } } diff --git a/src/android/build/localnotification.gradle b/src/android/build/localnotification.gradle index c82c7bedf..b8c20fa8c 100644 --- a/src/android/build/localnotification.gradle +++ b/src/android/build/localnotification.gradle @@ -24,9 +24,9 @@ repositories { } if (!project.ext.has('appShortcutBadgerVersion')) { - ext.appShortcutBadgerVersion = '1.1.19' + ext.appShortcutBadgerVersion = '1.1.22' } dependencies { - compile "me.leolin:ShortcutBadger:${appShortcutBadgerVersion}@aar" + implementation "me.leolin:ShortcutBadger:${appShortcutBadgerVersion}@aar" } diff --git a/src/android/notification/Builder.java b/src/android/notification/Builder.java index 9e3513108..1a566067a 100644 --- a/src/android/notification/Builder.java +++ b/src/android/notification/Builder.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -27,17 +28,22 @@ import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; -import android.support.v4.app.NotificationCompat; -import android.support.v4.app.NotificationCompat.MessagingStyle.Message; -import android.support.v4.media.app.NotificationCompat.MediaStyle; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationCompat.MessagingStyle.Message; +import androidx.media.app.NotificationCompat.MediaStyle; import android.support.v4.media.session.MediaSessionCompat; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.Paint; +import android.graphics.Canvas; import java.util.List; -import java.util.Random; import de.appplant.cordova.plugin.notification.action.Action; +import de.appplant.cordova.plugin.notification.util.LaunchUtils; -import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; import static de.appplant.cordova.plugin.notification.Notification.EXTRA_UPDATE; /** @@ -52,9 +58,6 @@ public final class Builder { // Notification options passed by JS private final Options options; - // To generate unique request codes - private final Random random = new Random(); - // Receiver to handle the clear event private Class clearReceiver; @@ -143,27 +146,41 @@ public Notification build() { .setTimeoutAfter(options.getTimeout()) .setLights(options.getLedColor(), options.getLedOn(), options.getLedOff()); - if (sound != Uri.EMPTY && !isUpdate()) { + if (!sound.equals(Uri.EMPTY) && !isUpdate()) { builder.setSound(sound); } + // API < 26. Setting sound to null will prevent playing if we have no sound for any reason, + // including a 0 volume. + if (options.isWithoutSound()) { + builder.setSound(null); + } + if (options.isWithProgressBar()) { builder.setProgress( options.getProgressMaxValue(), options.getProgressValue(), options.isIndeterminateProgress()); - } else { - // Only set this when no progressbar is used, to prevent a timer reset. - builder.setWhen(options.getWhen()); } if (options.hasLargeIcon()) { builder.setSmallIcon(options.getSmallIcon()); - builder.setLargeIcon(options.getLargeIcon()); + + Bitmap largeIcon = options.getLargeIcon(); + + if (options.getLargeIconType().equals("circle")) { + largeIcon = getCircleBitmap(largeIcon); + } + + builder.setLargeIcon(largeIcon); } else { builder.setSmallIcon(options.getSmallIcon()); } + if (options.useFullScreenIntent()) { + applyFullScreenIntent(builder); + } + applyStyle(builder); applyActions(builder); applyDeleteReceiver(builder); @@ -172,6 +189,56 @@ public Notification build() { return new Notification(context, options, builder); } + void applyFullScreenIntent(NotificationCompat.Builder builder) { + String pkgName = context.getPackageName(); + + int notificationId = options.getId(); + Intent intent = context + .getPackageManager() + .getLaunchIntentForPackage(pkgName) + .putExtra("launchNotificationId", notificationId); + + PendingIntent pendingIntent = + LaunchUtils.getActivityPendingIntent(context, intent, notificationId); + builder.setFullScreenIntent(pendingIntent, true); + } + + /** + * Convert a bitmap to a circular bitmap. + * This code has been extracted from the Phonegap Plugin Push plugin: + * https://github.com/phonegap/phonegap-plugin-push + * + * @param bitmap Bitmap to convert. + * @return Circular bitmap. + */ + private Bitmap getCircleBitmap(Bitmap bitmap) { + if (bitmap == null) { + return null; + } + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final int color = Color.RED; + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + //final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(color); + float cx = bitmap.getWidth() / 2.0f; + float cy = bitmap.getHeight() / 2.0f; + float radius = Math.min(cx, cy); + canvas.drawCircle(cx, cy, radius, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + bitmap.recycle(); + + return output; + } + /** * Find out and set the notification style. * @@ -316,19 +383,17 @@ private void applyDeleteReceiver(NotificationCompat.Builder builder) { if (clearReceiver == null) return; + int notificationId = options.getId(); Intent intent = new Intent(context, clearReceiver) .setAction(options.getIdentifier()) - .putExtra(Notification.EXTRA_ID, options.getId()); + .putExtra(Notification.EXTRA_ID, notificationId); if (extras != null) { intent.putExtras(extras); } - int reqCode = random.nextInt(); - - PendingIntent deleteIntent = PendingIntent.getBroadcast( - context, reqCode, intent, FLAG_UPDATE_CURRENT); - + PendingIntent deleteIntent = + LaunchUtils.getBroadcastPendingIntent(context, intent, notificationId); builder.setDeleteIntent(deleteIntent); } @@ -343,8 +408,16 @@ private void applyContentReceiver(NotificationCompat.Builder builder) { if (clickActivity == null) return; + Action[] actions = options.getActions(); + if (actions != null && actions.length > 0 ) { + // if actions are defined, the user must click on button actions to launch the app. + // Don't make the notification clickable in this case + return; + } + + int notificationId = options.getId(); Intent intent = new Intent(context, clickActivity) - .putExtra(Notification.EXTRA_ID, options.getId()) + .putExtra(Notification.EXTRA_ID, notificationId) .putExtra(Action.EXTRA_ID, Action.CLICK_ACTION_ID) .putExtra(Options.EXTRA_LAUNCH, options.isLaunchingApp()) .setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); @@ -353,11 +426,8 @@ private void applyContentReceiver(NotificationCompat.Builder builder) { intent.putExtras(extras); } - int reqCode = random.nextInt(); - - PendingIntent contentIntent = PendingIntent.getService( - context, reqCode, intent, FLAG_UPDATE_CURRENT); - + PendingIntent contentIntent = + LaunchUtils.getTaskStackPendingIntent(context, intent, notificationId); builder.setContentIntent(contentIntent); } @@ -393,8 +463,9 @@ private void applyActions (NotificationCompat.Builder builder) { * @param action Notification action needing the PendingIntent */ private PendingIntent getPendingIntentForAction (Action action) { + int notificationId = options.getId(); Intent intent = new Intent(context, clickActivity) - .putExtra(Notification.EXTRA_ID, options.getId()) + .putExtra(Notification.EXTRA_ID, notificationId) .putExtra(Action.EXTRA_ID, action.getId()) .putExtra(Options.EXTRA_LAUNCH, action.isLaunchingApp()) .setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); @@ -403,10 +474,7 @@ private PendingIntent getPendingIntentForAction (Action action) { intent.putExtras(extras); } - int reqCode = random.nextInt(); - - return PendingIntent.getService( - context, reqCode, intent, FLAG_UPDATE_CURRENT); + return LaunchUtils.getTaskStackPendingIntent(context, intent, notificationId); } /** @@ -415,7 +483,8 @@ private PendingIntent getPendingIntentForAction (Action action) { * @return true in case of an updated version. */ private boolean isUpdate() { - return extras != null && extras.getBoolean(EXTRA_UPDATE, false); + return extras != null + && extras.getBoolean(EXTRA_UPDATE, false); } /** diff --git a/src/android/notification/Manager.java b/src/android/notification/Manager.java index 0e1e2fdbc..08ef7c1e1 100644 --- a/src/android/notification/Manager.java +++ b/src/android/notification/Manager.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -23,15 +24,14 @@ package de.appplant.cordova.plugin.notification; -import android.annotation.SuppressLint; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.content.SharedPreferences; import android.media.AudioAttributes; -import android.media.RingtoneManager; +import android.net.Uri; import android.service.notification.StatusBarNotification; -import android.support.v4.app.NotificationManagerCompat; +import androidx.core.app.NotificationManagerCompat; import org.json.JSONException; import org.json.JSONObject; @@ -45,30 +45,18 @@ import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.O; -import static android.support.v4.app.NotificationCompat.PRIORITY_MIN; -import static android.support.v4.app.NotificationCompat.PRIORITY_LOW; -import static android.support.v4.app.NotificationCompat.PRIORITY_DEFAULT; -import static android.support.v4.app.NotificationCompat.PRIORITY_HIGH; -import static android.support.v4.app.NotificationCompat.PRIORITY_MAX; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_MIN; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_LOW; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_DEFAULT; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_HIGH; +import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT; +import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH; +import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW; import static de.appplant.cordova.plugin.notification.Notification.PREF_KEY_ID; import static de.appplant.cordova.plugin.notification.Notification.Type.TRIGGERED; -import de.appplant.cordova.plugin.notification.Options; /** - * Central way to access all or single local notifications set by specific - * state like triggered or scheduled. Offers shortcut ways to schedule, - * cancel or clear local notifications. + * Central way to access all or single local notifications set by specific state + * like triggered or scheduled. Offers shortcut ways to schedule, cancel or + * clear local notifications. */ public final class Manager { - - static final String DEFAULT_CHANNEL_ID = "default-channel-id"; - - static final String DEFAULT_CHANNEL_DESCRIPTION = "Default channel"; - // The application context private Context context; @@ -79,7 +67,6 @@ public final class Manager { */ private Manager(Context context) { this.context = context; - //createDefaultChannel(); } /** @@ -94,18 +81,18 @@ public static Manager getInstance(Context context) { /** * Check if app has local notification permission. */ - public boolean hasPermission () { + public boolean hasPermission() { return getNotCompMgr().areNotificationsEnabled(); } /** * Schedule local notification specified by request. * - * @param request Set of notification options. + * @param request Set of notification options. * @param receiver Receiver to handle the trigger event. */ - public Notification schedule (Request request, Class receiver) { - Options options = request.getOptions(); + public Notification schedule(Request request, Class receiver) { + Options options = request.getOptions(); Notification toast = new Notification(context, options); toast.schedule(request, receiver); @@ -114,63 +101,76 @@ public Notification schedule (Request request, Class receiver) { } /** - * TODO: temporary + * Build channel with options + * + * @param soundUri Uri for custom sound (empty to use default) + * @param shouldVibrate whether not vibration should occur during the + * notification + * @param hasSound whether or not sound should play during the notification + * @param channelName the name of the channel (null will pick an appropriate + * default name for the options provided). + * @return channel ID of newly created (or reused) channel + */ + public String buildChannelWithOptions(Uri soundUri, boolean shouldVibrate, boolean hasSound, + CharSequence channelName, String channelId) { + String defaultChannelId, newChannelId; + CharSequence defaultChannelName; + int importance; + + if (hasSound && shouldVibrate) { + defaultChannelId = Options.SOUND_VIBRATE_CHANNEL_ID; + defaultChannelName = Options.SOUND_VIBRATE_CHANNEL_NAME; + importance = IMPORTANCE_HIGH; + shouldVibrate = true; + } else if (hasSound) { + defaultChannelId = Options.SOUND_CHANNEL_ID; + defaultChannelName = Options.SOUND_CHANNEL_NAME; + importance = IMPORTANCE_DEFAULT; + shouldVibrate = false; + } else if (shouldVibrate) { + defaultChannelId = Options.VIBRATE_CHANNEL_ID; + defaultChannelName = Options.VIBRATE_CHANNEL_NAME; + importance = IMPORTANCE_LOW; + shouldVibrate = true; + } else { + defaultChannelId = Options.DEFAULT_CHANNEL_ID; + defaultChannelName = "Default Channel"; + importance = IMPORTANCE_HIGH; + shouldVibrate = true; + } + + newChannelId = channelId != null ? channelId : defaultChannelId; + + createChannel(newChannelId, channelName != null ? channelName : defaultChannelName, importance, shouldVibrate, + soundUri); + + return newChannelId; + } + + /** + * Create a channel */ - @SuppressLint("WrongConstant") - public void createChannel(Options options) { + public void createChannel(String channelId, CharSequence channelName, int importance, Boolean shouldVibrate, + Uri soundUri) { NotificationManager mgr = getNotMgr(); - int importance = IMPORTANCE_DEFAULT; if (SDK_INT < O) return; - NotificationChannel channel = mgr.getNotificationChannel(options.getChannel()); + NotificationChannel channel = mgr.getNotificationChannel(channelId); if (channel != null) return; - switch (options.getPrio()) { - case PRIORITY_MIN: - importance = IMPORTANCE_MIN; - break; - case PRIORITY_LOW: - importance = IMPORTANCE_LOW; - break; - case PRIORITY_DEFAULT: - importance = IMPORTANCE_DEFAULT; - break; - case PRIORITY_HIGH: - importance = IMPORTANCE_HIGH; - break; - case PRIORITY_MAX: - importance = IMPORTANCE_HIGH; - break; - } + channel = new NotificationChannel(channelId, channelName, importance); - channel = new NotificationChannel( - options.getChannel(), options.getChannelDescription(), importance); - if(!options.isSilent() && importance > IMPORTANCE_DEFAULT) channel.setBypassDnd(true); - if(!options.isWithoutLights()) channel.enableLights(true); - if(options.isWithVibration()) { - channel.enableVibration(true); - } else { - channel.setVibrationPattern(new long[]{ 0 }); - channel.enableVibration(true); - } - channel.setLightColor(options.getLedColor()); - if(options.isWithoutSound()) { - channel.setSound(null, null); - } else { - AudioAttributes audioAttributes = new AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_NOTIFICATION).build(); - - if(options.isWithDefaultSound()) { - channel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), audioAttributes); - } else { - channel.setSound(options.getSound(), audioAttributes); - } - } + channel.enableVibration(shouldVibrate); + + if (!soundUri.equals(Uri.EMPTY)) { + AudioAttributes attributes = new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build(); + channel.setSound(soundUri, attributes); + } mgr.createNotificationChannel(channel); } @@ -182,7 +182,7 @@ public void createChannel(Options options) { * @param updates JSON object with notification options. * @param receiver Receiver to handle the trigger event. */ - public Notification update (int id, JSONObject updates, Class receiver) { + public Notification update(int id, JSONObject updates, Class receiver) { Notification notification = get(id); if (notification == null) @@ -198,7 +198,7 @@ public Notification update (int id, JSONObject updates, Class receiver) { * * @param id The notification ID. */ - public Notification clear (int id) { + public Notification clear(int id) { Notification toast = get(id); if (toast != null) { @@ -211,7 +211,7 @@ public Notification clear (int id) { /** * Clear all local notifications. */ - public void clearAll () { + public void clearAll() { List toasts = getByType(TRIGGERED); for (Notification toast : toasts) { @@ -227,7 +227,7 @@ public void clearAll () { * * @param id The notification ID */ - public Notification cancel (int id) { + public Notification cancel(int id) { Notification toast = get(id); if (toast != null) { @@ -240,7 +240,7 @@ public Notification cancel (int id) { /** * Cancel all local notifications. */ - public void cancelAll () { + public void cancelAll() { List notifications = getAll(); for (Notification notification : notifications) { @@ -280,7 +280,7 @@ public List getIdsByType(Notification.Type type) { return getIds(); StatusBarNotification[] activeToasts = getActiveNotifications(); - List activeIds = new ArrayList(); + List activeIds = new ArrayList(); for (StatusBarNotification toast : activeToasts) { activeIds.add(toast.getId()); @@ -365,8 +365,7 @@ public List getOptionsById(List ids) { /** * List of properties from all local notifications from given type. * - * @param type - * The notification life cycle type + * @param type The notification life cycle type */ public List getOptionsByType(Notification.Type type) { ArrayList options = new ArrayList(); @@ -388,13 +387,13 @@ public List getOptionsByType(Notification.Type type) { */ public Options getOptions(int id) { SharedPreferences prefs = getPrefs(); - String toastId = Integer.toString(id); + String toastId = Integer.toString(id); if (!prefs.contains(toastId)) return null; try { - String json = prefs.getString(toastId, null); + String json = prefs.getString(toastId, null); JSONObject dict = new JSONObject(json); return new Options(context, dict); @@ -425,7 +424,7 @@ public Notification get(int id) { * * @param badge The badge number. */ - public void setBadge (int badge) { + public void setBadge(int badge) { if (badge == 0) { new BadgeImpl(context).clearBadge(); } else { @@ -447,7 +446,7 @@ StatusBarNotification[] getActiveNotifications() { /** * Shared private preferences for the application. */ - private SharedPreferences getPrefs () { + private SharedPreferences getPrefs() { return context.getSharedPreferences(PREF_KEY_ID, Context.MODE_PRIVATE); } @@ -455,16 +454,16 @@ private SharedPreferences getPrefs () { * Notification manager for the application. */ private NotificationManager getNotMgr() { - return (NotificationManager) context.getSystemService( - Context.NOTIFICATION_SERVICE); + return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); } /** * Notification compat manager for the application. */ - public NotificationManagerCompat getNotCompMgr() { + private NotificationManagerCompat getNotCompMgr() { return NotificationManagerCompat.from(context); } + } // codebeat:enable[TOO_MANY_FUNCTIONS] diff --git a/src/android/notification/Notification.java b/src/android/notification/Notification.java index c3bc3e673..1fd7ce6ba 100644 --- a/src/android/notification/Notification.java +++ b/src/android/notification/Notification.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -30,9 +31,7 @@ import android.content.SharedPreferences; import android.net.Uri; import android.service.notification.StatusBarNotification; -import android.support.v4.app.NotificationCompat; -import android.support.v4.util.ArraySet; -import android.support.v4.util.Pair; +import android.util.Pair; import android.util.Log; import android.util.SparseArray; @@ -44,17 +43,18 @@ import java.util.Iterator; import java.util.List; import java.util.Set; +import androidx.collection.ArraySet; +import androidx.core.app.NotificationCompat; import static android.app.AlarmManager.RTC; import static android.app.AlarmManager.RTC_WAKEUP; -import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.M; -import static android.support.v4.app.NotificationCompat.PRIORITY_HIGH; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_MIN; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_LOW; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_MAX; -import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_HIGH; +import static androidx.core.app.NotificationCompat.PRIORITY_HIGH; +import static androidx.core.app.NotificationCompat.PRIORITY_MAX; +import static androidx.core.app.NotificationCompat.PRIORITY_MIN; + +import de.appplant.cordova.plugin.notification.util.LaunchUtils; /** * Wrapper class around OS notification class. Handles basic operations @@ -218,16 +218,16 @@ void schedule(Request request, Class receiver) { if (!date.after(new Date()) && trigger(intent, receiver)) continue; - - PendingIntent pi = PendingIntent.getBroadcast( - context, 0, intent, FLAG_CANCEL_CURRENT); + int notificationId = options.getId(); + PendingIntent pi = + LaunchUtils.getBroadcastPendingIntent(context, intent, notificationId); try { switch (options.getPrio()) { - case IMPORTANCE_MIN: case IMPORTANCE_LOW: + case PRIORITY_MIN: mgr.setExact(RTC, time, pi); break; - case IMPORTANCE_MAX: case IMPORTANCE_HIGH: + case PRIORITY_MAX: case PRIORITY_HIGH: if (SDK_INT >= M) { mgr.setExactAndAllowWhileIdle(RTC_WAKEUP, time, pi); } else { @@ -297,7 +297,8 @@ public void cancel() { */ private void cancelScheduledAlarms() { SharedPreferences prefs = getPrefs(PREF_KEY_PID); - String id = options.getIdentifier(); + String id = options.getIdentifier(); + int notificationId = options.getId(); Set actions = prefs.getStringSet(id, null); if (actions == null) @@ -305,10 +306,7 @@ private void cancelScheduledAlarms() { for (String action : actions) { Intent intent = new Intent(action); - - PendingIntent pi = PendingIntent.getBroadcast( - context, 0, intent, 0); - + PendingIntent pi = LaunchUtils.getBroadcastPendingIntent(context, intent, notificationId); if (pi != null) { getAlarmMgr().cancel(pi); } @@ -326,6 +324,8 @@ public void show() { } grantPermissionToPlaySoundFromExternal(); + new NotificationVolumeManager(context, options) + .adjustAlarmVolume(); getNotMgr().notify(getId(), builder.build()); } diff --git a/src/android/notification/NotificationVolumeManager.java b/src/android/notification/NotificationVolumeManager.java new file mode 100644 index 000000000..7fb77c33d --- /dev/null +++ b/src/android/notification/NotificationVolumeManager.java @@ -0,0 +1,233 @@ +package de.appplant.cordova.plugin.notification; + +import android.annotation.SuppressLint; +import android.app.NotificationManager; +import android.content.Context; +import android.content.SharedPreferences; +import android.media.AudioManager; +import android.util.Log; + +import java.util.Timer; +import java.util.TimerTask; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.M; +import static java.lang.Thread.sleep; + +/** + * Class to handle all notification volume changes + */ +public class NotificationVolumeManager { + /** + * Amount of time to sleep while polling to see if all volume writers are closed. + */ + final private int VOLUME_WRITER_POLLING_DURATION = 200; + + /** + * Key for volume writer counter in shared preferences + */ + final private String VOLUME_CONFIG_WRITER_COUNT_KEY = "volumeConfigWriterCount"; + + /** + * Tag for logs + */ + final String TAG = "NotificationVolumeMgr"; + + /** + * Notification manager + */ + private NotificationManager notificationManager; + + /** + * Audio Manager + */ + private AudioManager audioManager; + + /** + * Shared preferences, used to store settings across processes + */ + private SharedPreferences settings; + + /** + * Options for the notification + */ + private Options options; + + /** + * Initialize the NotificationVolumeManager + * @param context Application context + */ + public NotificationVolumeManager (Context context, Options options) { + this.settings = context.getSharedPreferences(context.getPackageName(), Context.MODE_PRIVATE); + this.notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE); + this.audioManager = (AudioManager)context.getSystemService(Context.AUDIO_SERVICE); + this.options = options; + } + + /** + * Ensure that this is the only volume writer. + * Wait until others have closed. + * TODO: Better locking mechanism to ensure concurrency (file lock?) + * @throws InterruptedException Throws an interrupted exception, required by sleep call. + */ + @SuppressLint("ApplySharedPref") + private void ensureOnlyVolumeWriter () throws InterruptedException { + int writerCount = settings.getInt(VOLUME_CONFIG_WRITER_COUNT_KEY, 0) + 1; + settings.edit().putInt(VOLUME_CONFIG_WRITER_COUNT_KEY, writerCount).commit(); + + int resetDelay = options.getResetDelay(); + if (resetDelay == 0) { + resetDelay = Options.DEFAULT_RESET_DELAY; + } + + int resetDelayMs = resetDelay * 1000; + int sleepTotal = 0; + + // Wait until we are the only writer left. + while(writerCount > 1) { + if (sleepTotal > resetDelayMs) { + throw new InterruptedException("Volume writer timeout exceeded reset delay." + + "Something must have gone wrong. Reset volume writer counts to 0 " + + "and reset volume settings to user settings."); + } + + sleep(VOLUME_WRITER_POLLING_DURATION); + sleepTotal += VOLUME_WRITER_POLLING_DURATION; + + writerCount = settings.getInt(VOLUME_CONFIG_WRITER_COUNT_KEY, 0); + } + } + + /** + * Remove one count from active volume writers. Used when writer is finished. + */ + @SuppressLint("ApplySharedPref") + private void decrementVolumeWriter () { + int writerCount = settings.getInt(VOLUME_CONFIG_WRITER_COUNT_KEY, 0) - 1; + settings.edit().putInt(VOLUME_CONFIG_WRITER_COUNT_KEY, Math.max(writerCount, 0)).commit(); + } + + /** + * Reset volume writer counts to 0. To be used in error conditions. + */ + @SuppressLint("ApplySharedPref") + private void resetVolumeWriter () { + settings.edit().putInt(VOLUME_CONFIG_WRITER_COUNT_KEY, 0).commit(); + } + + /** + * Set the volume for our ringer + * @param ringerMode ringer mode enum. Normal ringer or vibration. + * @param volume volume. + */ + private void setVolume (int ringerMode, int volume) { + // After delay, user could have set phone to do not disturb. + // If so and we can't change the ringer, quit so we don't create an error condition + if (canChangeRinger()) { + // Change ringer mode + audioManager.setRingerMode(ringerMode); + + // Change to new Volume + audioManager.setStreamVolume(AudioManager.STREAM_NOTIFICATION, volume, AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE); + } + } + + /** + * Set the volume to the last user settings from shared preferences. + */ + private void setVolumeToUserSettings () { + int ringMode = settings.getInt("userRingerMode", -1); + int volume = settings.getInt("userVolume", -1); + + setVolume(ringMode, volume); + } + + /** + * Figure out if we can change the ringer. + * In Android M+, we can't change out of do not disturb if we don't have explicit permission. + * @return whether or not we can change the ringer. + */ + private boolean canChangeRinger() { + return SDK_INT < M || notificationManager.isNotificationPolicyAccessGranted() + || audioManager.getRingerMode() != AudioManager.RINGER_MODE_SILENT; + } + + /** + * Adjusts alarm Volume + * Options object. Contains our volume, reset and vibration settings. + */ + @SuppressLint("ApplySharedPref") + public void adjustAlarmVolume () { + Integer volume = options.getVolume(); + + if (volume.equals(Options.VOLUME_NOT_SET) || !canChangeRinger()) { + return; + } + + try { + ensureOnlyVolumeWriter(); + + boolean vibrate = options.isWithVibration(); + + int delay = options.getResetDelay(); + + if (delay <= 0) { + delay = Options.DEFAULT_RESET_DELAY; + } + + // Count of all alarms currently sounding + Integer count = settings.getInt("alarmCount", 0); + settings.edit().putInt("alarmCount", count + 1).commit(); + + // Get current phone volume + int userVolume = audioManager.getStreamVolume(AudioManager.STREAM_NOTIFICATION); + + // Get Ringer mode + int userRingerMode = audioManager.getRingerMode(); + + // If this is the first alarm store the users ringer and volume settings + if (count.equals(0)) { + settings.edit().putInt("userVolume", userVolume).apply(); + settings.edit().putInt("userRingerMode", userRingerMode).apply(); + } + + // Calculates a new volume based on the study configure volume percentage and the devices max volume integer + if (volume > 0) { + // Gets devices max volume integer + int maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_NOTIFICATION); + + // Calculates new volume based on devices max volume + double newVolume = Math.ceil(maxVolume * (volume / 100.00)); + + setVolume(AudioManager.RINGER_MODE_NORMAL, (int) newVolume); + } else { + // Volume of 0 + if (vibrate) { + // Change mode to vibrate + setVolume(AudioManager.RINGER_MODE_VIBRATE, 0); + } + } + + // Timer to change users sound back + Timer timer = new Timer(); + timer.schedule(new TimerTask() { + public void run() { + int currentCount = settings.getInt("alarmCount", 0); + currentCount = Math.max(currentCount - 1, 0); + settings.edit().putInt("alarmCount", currentCount).apply(); + + if (currentCount == 0) { + setVolumeToUserSettings(); + } + } + }, delay * 1000); + } catch (InterruptedException e) { + Log.e(TAG, "interrupted waiting for volume set. " + + "Reset to user setting, and set counts to 0: " + e.toString()); + resetVolumeWriter(); + setVolumeToUserSettings(); + } finally { + decrementVolumeWriter(); + } + } +} diff --git a/src/android/notification/Options.java b/src/android/notification/Options.java index efefcb4be..ecb206ddb 100644 --- a/src/android/notification/Options.java +++ b/src/android/notification/Options.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -27,14 +28,13 @@ import android.graphics.Bitmap; import android.graphics.Color; import android.net.Uri; -import android.support.v4.app.NotificationCompat; -import android.support.v4.app.NotificationCompat.MessagingStyle.Message; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationCompat.MessagingStyle.Message; import android.support.v4.media.session.MediaSessionCompat; import org.json.JSONArray; import org.json.JSONObject; -import java.lang.System; import java.io.IOException; import java.util.ArrayList; import java.util.Date; @@ -44,20 +44,40 @@ import de.appplant.cordova.plugin.notification.action.ActionGroup; import de.appplant.cordova.plugin.notification.util.AssetUtil; -import static android.support.v4.app.NotificationCompat.DEFAULT_LIGHTS; -import static android.support.v4.app.NotificationCompat.DEFAULT_SOUND; -import static android.support.v4.app.NotificationCompat.DEFAULT_VIBRATE; -import static android.support.v4.app.NotificationCompat.PRIORITY_MAX; -import static android.support.v4.app.NotificationCompat.PRIORITY_MIN; -import static android.support.v4.app.NotificationCompat.VISIBILITY_PUBLIC; -import static android.support.v4.app.NotificationCompat.VISIBILITY_SECRET; +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.O; +import static androidx.core.app.NotificationCompat.DEFAULT_LIGHTS; +import static androidx.core.app.NotificationCompat.DEFAULT_SOUND; +import static androidx.core.app.NotificationCompat.DEFAULT_VIBRATE; +import static androidx.core.app.NotificationCompat.PRIORITY_MAX; +import static androidx.core.app.NotificationCompat.PRIORITY_MIN; +import static androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC; +import static androidx.core.app.NotificationCompat.VISIBILITY_SECRET; /** - * Wrapper around the JSON object passed through JS which contains all - * possible option values. Class provides simple readers and more advanced - * methods to convert independent values into platform specific values. + * Wrapper around the JSON object passed through JS which contains all possible + * option values. Class provides simple readers and more advanced methods to + * convert independent values into platform specific values. */ public final class Options { + // Default Channel ID for SDK < 26 + static final String DEFAULT_CHANNEL_ID = "default-channel-id"; + + // Silent channel + static final String SILENT_CHANNEL_ID = "silent-channel-id"; + static final CharSequence SILENT_CHANNEL_NAME = "Silent Notifications"; + + // Vibrate only channel + static final String VIBRATE_CHANNEL_ID = "vibrate-channel-id"; + static final CharSequence VIBRATE_CHANNEL_NAME = "Low Priority Notifications"; + + // Sound only channel + static final String SOUND_CHANNEL_ID = "sound-channel-id"; + static final CharSequence SOUND_CHANNEL_NAME = "Medium Priority Notifications"; + + // Sound and vibrate channel + static final String SOUND_VIBRATE_CHANNEL_ID = "sound-vibrate-channel-id"; + static final CharSequence SOUND_VIBRATE_CHANNEL_NAME = "High Priority Notifications"; // Key name for bundled sound extra static final String EXTRA_SOUND = "NOTIFICATION_SOUND"; @@ -68,6 +88,16 @@ public final class Options { // Default icon path private static final String DEFAULT_ICON = "res://icon"; + public final static Integer DEFAULT_RESET_DELAY = 5; + + public final static Integer VOLUME_NOT_SET = -1; + + // Default wakelock timeout + public final static Integer DEFAULT_WAKE_LOCK_TIMEOUT = 15000; + + // Default icon type + private static final String DEFAULT_ICON_TYPE = "square"; + // The original JSON object private final JSONObject options; @@ -85,7 +115,7 @@ public final class Options { public Options(JSONObject options) { this.options = options; this.context = null; - this.assets = null; + this.assets = null; } /** @@ -97,13 +127,13 @@ public Options(JSONObject options) { public Options(Context context, JSONObject options) { this.context = context; this.options = options; - this.assets = AssetUtil.getInstance(context); + this.assets = AssetUtil.getInstance(context); } /** * Application context. */ - public Context getContext () { + public Context getContext() { return context; } @@ -195,6 +225,13 @@ boolean isLaunchingApp() { return options.optBoolean("launch", true); } + /** + * flag to auto-launch the application as the notification fires + */ + public boolean isAutoLaunchingApp() { + return options.optBoolean("autoLaunch", true); + } + /** * wakeup flag for the notification. */ @@ -202,6 +239,23 @@ public boolean shallWakeUp() { return options.optBoolean("wakeup", true); } + /** + * Use a fullScreenIntent + */ + public boolean useFullScreenIntent() { return options.optBoolean("fullScreenIntent", true); } + + /** + * Whether or not to trigger a notification in the app. + */ + public boolean triggerInApp() { return options.optBoolean("triggerInApp", false); } + + /** + * Timeout for wakeup (only used if shallWakeUp() is true) + */ + public int getWakeLockTimeout() { + return options.optInt("wakeLockTimeout", DEFAULT_WAKE_LOCK_TIMEOUT); + } + /** * Gets the value for the timeout flag. */ @@ -212,8 +266,24 @@ long getTimeout() { /** * The channel id of that notification. */ - String getChannel() { return options.optString("channel", Manager.DEFAULT_CHANNEL_ID); } - String getChannelDescription() { return options.optString("channelDescription", Manager.DEFAULT_CHANNEL_DESCRIPTION); } + String getChannel() { + // If we have a low enough SDK for it not to matter, + // short-circuit. + if (SDK_INT < O) { + return DEFAULT_CHANNEL_ID; + } + + Uri soundUri = getSound(); + boolean hasSound = !isWithoutSound(); + boolean shouldVibrate = isWithVibration(); + CharSequence channelName = options.optString("channelName", null); + String channelId = options.optString("channelId", null); + + channelId = Manager.getInstance(context).buildChannelWithOptions(soundUri, shouldVibrate, hasSound, channelName, + channelId); + + return channelId; + } /** * If the group shall show a summary. @@ -237,8 +307,7 @@ public String getTitle() { String title = options.optString("title", ""); if (title.isEmpty()) { - title = context.getApplicationInfo().loadLabel( - context.getPackageManager()).toString(); + title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); } return title; @@ -253,11 +322,9 @@ int getLedColor() { if (cfg instanceof String) { hex = options.optString("led"); - } else - if (cfg instanceof JSONArray) { + } else if (cfg instanceof JSONArray) { hex = options.optJSONArray("led").optString(0); - } else - if (cfg instanceof JSONObject) { + } else if (cfg instanceof JSONObject) { hex = options.optJSONObject("led").optString("color"); } @@ -265,7 +332,7 @@ int getLedColor() { return 0; try { - hex = stripHex(hex); + hex = stripHex(hex); int aRGB = Integer.parseInt(hex, 16); return aRGB + 0xFF000000; @@ -323,9 +390,7 @@ public int getColor() { hex = stripHex(hex); if (hex.matches("[^0-9]*")) { - return Color.class - .getDeclaredField(hex.toUpperCase()) - .getInt(null); + return Color.class.getDeclaredField(hex.toUpperCase()).getInt(null); } int aRGB = Integer.parseInt(hex, 16); @@ -361,33 +426,36 @@ boolean hasLargeIcon() { */ Bitmap getLargeIcon() { String icon = options.optString("icon", null); - Uri uri = assets.parse(icon); - Bitmap bmp = null; + Uri uri = assets.parse(icon); + Bitmap bmp = null; try { bmp = assets.getIconFromUri(uri); - } catch (Exception e){ + } catch (Exception e) { e.printStackTrace(); } return bmp; } + /** + * Type of the large icon. + */ + String getLargeIconType() { + return options.optString("iconType", DEFAULT_ICON_TYPE); + } + /** * Small icon resource ID for the local notification. */ int getSmallIcon() { String icon = options.optString("smallIcon", DEFAULT_ICON); - int resId = assets.getResId(icon); + int resId = assets.getResId(icon); if (resId == 0) { resId = assets.getResId(DEFAULT_ICON); } - if (resId == 0) { - resId = context.getApplicationInfo().icon; - } - if (resId == 0) { resId = android.R.drawable.ic_popup_reminder; } @@ -395,6 +463,23 @@ int getSmallIcon() { return resId; } + /** + * Get the volume + */ + public Integer getVolume() { + return options.optInt("alarmVolume", VOLUME_NOT_SET); + } + + /** + * Returns the resetDelay until the sound changes revert back to the users + * settings. + * + * @return resetDelay + */ + public Integer getResetDelay() { + return options.optInt("resetDelay", DEFAULT_RESET_DELAY); + } + /** * If the phone should vibrate. */ @@ -407,7 +492,7 @@ public boolean isWithVibration() { */ public boolean isWithoutSound() { Object value = options.opt("sound"); - return value == null || value.equals(false); + return value == null || value.equals(false) || options.optInt("alarmVolume") == 0; } /** @@ -421,7 +506,7 @@ public boolean isWithDefaultSound() { /** * If the phone should show no LED light. */ - public boolean isWithoutLights() { + private boolean isWithoutLights() { Object value = options.opt("led"); return value == null || value.equals(false); } @@ -429,15 +514,15 @@ public boolean isWithoutLights() { /** * If the phone should show the default LED lights. */ - public boolean isWithDefaultLights() { + private boolean isWithDefaultLights() { Object value = options.opt("led"); return value != null && value.equals(true); } /** - * Set the default notification options that will be used. - * The value should be one or more of the following fields combined with - * bitwise-or: DEFAULT_SOUND, DEFAULT_VIBRATE, DEFAULT_LIGHTS. + * Set the default notification options that will be used. The value should be + * one or more of the following fields combined with bitwise-or: DEFAULT_SOUND, + * DEFAULT_VIBRATE, DEFAULT_LIGHTS. */ int getDefaults() { int defaults = options.optInt("defaults", 0); @@ -450,15 +535,13 @@ int getDefaults() { if (isWithDefaultSound()) { defaults |= DEFAULT_SOUND; - } else - if (isWithoutSound()) { + } else if (isWithoutSound()) { defaults &= DEFAULT_SOUND; } if (isWithDefaultLights()) { defaults |= DEFAULT_LIGHTS; - } else - if (isWithoutLights()) { + } else if (isWithoutLights()) { defaults &= DEFAULT_LIGHTS; } @@ -482,18 +565,11 @@ int getVisibility() { * Gets the notifications priority. */ int getPrio() { - return Math.min(Math.max(options.optInt("priority"), PRIORITY_MIN), PRIORITY_MAX); - } - - /** - * Set the when date for the notification. - */ - long getWhen() { - long when = options.optLong("when"); + int prio = options.optInt("priority"); - return (when != 0) ? when : System.currentTimeMillis(); + return Math.min(Math.max(prio, PRIORITY_MIN), PRIORITY_MAX); } - + /** * If the notification shall show the when date. */ @@ -516,9 +592,7 @@ boolean showChronometer() { * If the notification shall display a progress bar. */ boolean isWithProgressBar() { - return options - .optJSONObject("progressBar") - .optBoolean("enabled", false); + return options.optJSONObject("progressBar").optBoolean("enabled", false); } /** @@ -527,9 +601,7 @@ boolean isWithProgressBar() { * @return 0 by default. */ int getProgressValue() { - return options - .optJSONObject("progressBar") - .optInt("value", 0); + return options.optJSONObject("progressBar").optInt("value", 0); } /** @@ -538,9 +610,7 @@ int getProgressValue() { * @return 100 by default. */ int getProgressMaxValue() { - return options - .optJSONObject("progressBar") - .optInt("maxValue", 100); + return options.optJSONObject("progressBar").optInt("maxValue", 100); } /** @@ -549,9 +619,7 @@ int getProgressMaxValue() { * @return false by default. */ boolean isIndeterminateProgress() { - return options - .optJSONObject("progressBar") - .optBoolean("indeterminate", false); + return options.optJSONObject("progressBar").optBoolean("indeterminate", false); } /** @@ -573,11 +641,11 @@ String getSummary() { /** * Image attachments for image style notifications. * - * @return For now it only returns the first item as Android does not - * support multiple attachments like iOS. + * @return For now it only returns the first item as Android does not support + * multiple attachments like iOS. */ List getAttachments() { - JSONArray paths = options.optJSONArray("attachments"); + JSONArray paths = options.optJSONArray("attachments"); List pics = new ArrayList(); if (paths == null) @@ -605,22 +673,20 @@ List getAttachments() { * Gets the list of actions to display. */ Action[] getActions() { - Object value = options.opt("actions"); - String groupId = null; + Object value = options.opt("actions"); + String groupId = null; JSONArray actions = null; ActionGroup group = null; if (value instanceof String) { groupId = (String) value; - } else - if (value instanceof JSONArray) { + } else if (value instanceof JSONArray) { actions = (JSONArray) value; } if (groupId != null) { group = ActionGroup.lookup(groupId); - } else - if (actions != null && actions.length() > 0) { + } else if (actions != null && actions.length() > 0) { group = ActionGroup.parse(context, actions); } @@ -644,13 +710,13 @@ Message[] getMessages() { return null; Message[] messages = new Message[list.length()]; - long now = new Date().getTime(); + long now = new Date().getTime(); for (int i = 0; i < messages.length; i++) { JSONObject msg = list.optJSONObject(i); String message = msg.optString("message"); long timestamp = msg.optLong("date", now); - String person = msg.optString("person", null); + String person = msg.optString("person", null); messages[i] = new Message(message, timestamp, person); } diff --git a/src/android/notification/Request.java b/src/android/notification/Request.java index 807cce0a3..06260bf2b 100644 --- a/src/android/notification/Request.java +++ b/src/android/notification/Request.java @@ -258,6 +258,9 @@ private List getSpecialMatchingComponents() { * Gets the base date from where to calculate the next trigger date. */ private Date getBaseDate() { + if (spec.has("requestBaseDate")) { + return new Date(spec.optLong("requestBaseDate")); + } else if (spec.has("at")) { return new Date(spec.optLong("at", 0)); } else diff --git a/src/android/notification/action/Action.java b/src/android/notification/action/Action.java index 9b7c5057c..9d5440bb8 100644 --- a/src/android/notification/action/Action.java +++ b/src/android/notification/action/Action.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -22,7 +23,7 @@ package de.appplant.cordova.plugin.notification.action; import android.content.Context; -import android.support.v4.app.RemoteInput; +import androidx.core.app.RemoteInput; import org.json.JSONArray; import org.json.JSONObject; @@ -134,4 +135,4 @@ private String[] getChoices() { return choices; } -} \ No newline at end of file +} diff --git a/src/android/notification/action/ActionGroup.java b/src/android/notification/action/ActionGroup.java index ec85f0993..7a5675345 100644 --- a/src/android/notification/action/ActionGroup.java +++ b/src/android/notification/action/ActionGroup.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License diff --git a/src/android/notification/receiver/AbstractClearReceiver.java b/src/android/notification/receiver/AbstractClearReceiver.java index d6c9e18bb..1272a0d0f 100644 --- a/src/android/notification/receiver/AbstractClearReceiver.java +++ b/src/android/notification/receiver/AbstractClearReceiver.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License diff --git a/src/android/notification/receiver/AbstractClickReceiver.java b/src/android/notification/receiver/AbstractClickReceiver.java index 4177c1750..8300ad81b 100644 --- a/src/android/notification/receiver/AbstractClickReceiver.java +++ b/src/android/notification/receiver/AbstractClickReceiver.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -21,7 +22,6 @@ package de.appplant.cordova.plugin.notification.receiver; -import android.app.IntentService; import android.content.Context; import android.content.Intent; import android.os.Bundle; @@ -29,8 +29,6 @@ import de.appplant.cordova.plugin.notification.Manager; import de.appplant.cordova.plugin.notification.Notification; -import static android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT; -import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP; import static de.appplant.cordova.plugin.notification.action.Action.CLICK_ACTION_ID; import static de.appplant.cordova.plugin.notification.action.Action.EXTRA_ID; @@ -38,21 +36,22 @@ * Abstract content receiver activity for local notifications. Creates the * local notification and calls the event functions for further proceeding. */ -abstract public class AbstractClickReceiver extends IntentService { - - // Holds a reference to the intent to handle. - private Intent intent; +abstract public class AbstractClickReceiver extends NotificationTrampolineActivity { public AbstractClickReceiver() { - super("LocalNotificationClickReceiver"); + super(); + } + + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + onHandleIntent(getIntent()); } /** * Called when local notification was clicked to launch the main intent. */ - @Override protected void onHandleIntent(Intent intent) { - this.intent = intent; + // Holds a reference to the intent to handle. if (intent == null) return; @@ -70,7 +69,6 @@ protected void onHandleIntent(Intent intent) { return; onClick(toast, bundle); - this.intent = null; } /** @@ -87,33 +85,4 @@ protected void onHandleIntent(Intent intent) { protected String getAction() { return getIntent().getExtras().getString(EXTRA_ID, CLICK_ACTION_ID); } - - /** - * Getter for the received intent. - */ - protected Intent getIntent() { - return intent; - } - - /** - * Launch main intent from package. - */ - protected void launchApp() { - Context context = getApplicationContext(); - String pkgName = context.getPackageName(); - - Intent intent = context - .getPackageManager() - .getLaunchIntentForPackage(pkgName); - - if (intent == null) - return; - - intent.addFlags( - FLAG_ACTIVITY_REORDER_TO_FRONT - | FLAG_ACTIVITY_SINGLE_TOP); - - context.startActivity(intent); - } - } diff --git a/src/android/notification/receiver/AbstractNotificationReceiver.java b/src/android/notification/receiver/AbstractNotificationReceiver.java new file mode 100644 index 000000000..05a4e1a73 --- /dev/null +++ b/src/android/notification/receiver/AbstractNotificationReceiver.java @@ -0,0 +1,152 @@ +package de.appplant.cordova.plugin.notification.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.os.PowerManager; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Calendar; + +import de.appplant.cordova.plugin.localnotification.LocalNotification; +import de.appplant.cordova.plugin.notification.Manager; +import de.appplant.cordova.plugin.notification.Notification; +import de.appplant.cordova.plugin.notification.Options; +import de.appplant.cordova.plugin.notification.Request; +import de.appplant.cordova.plugin.notification.util.LaunchUtils; + +import static android.content.Context.POWER_SERVICE; +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.O; +import static android.os.Build.VERSION_CODES.P; +import static java.util.Calendar.MINUTE; + +/** + * The base class for any receiver that is trying to display a notification. + */ +abstract public class AbstractNotificationReceiver extends BroadcastReceiver { + private final String TAG = "AbstractNotification"; + + /** + * Perform a notification. All notification logic is here. + * Determines whether to dispatch events, autoLaunch the app, use fullScreenIntents, etc. + * @param notification reference to the notification to be fired + */ + public void performNotification(Notification notification) { + Context context = notification.getContext(); + Options options = notification.getOptions(); + Manager manager = Manager.getInstance(context); + PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE); + boolean autoLaunch = options.isAutoLaunchingApp() && SDK_INT <= P && !options.useFullScreenIntent(); + + int badge = options.getBadgeNumber(); + + if (badge > 0) { + manager.setBadge(badge); + } + + if (options.shallWakeUp()) { + wakeUp(notification); + } + + if (autoLaunch) { + LaunchUtils.launchApp(context); + } + + // Show notification if we should (triggerInApp is false) + // or if we can't trigger in the app due to: + // 1. No autoLaunch configured/supported and app is not running. + // 2. Any SDK >= Oreo is asleep (must be triggered here) + boolean didShowNotification = false; + if (!options.triggerInApp() + || (checkAppRunning() && !LocalNotification.isInForeground() ) + || (!checkAppRunning() && !autoLaunch ) + ) { + didShowNotification = true; + notification.show(); + } + + // run trigger function if triggerInApp() is true + // and we did not send a notification. + if (options.triggerInApp() && !didShowNotification) { + // wake up even if we didn't set it to + if (!options.shallWakeUp()) { + wakeUp(notification); + } + + dispatchAppEvent("trigger", notification); + } + + if (!options.isInfiniteTrigger()) + return; + + Calendar cal = Calendar.getInstance(); + cal.add(MINUTE, 1); + + Request req = new Request( + getOptionsWithBaseDate(options, cal.getTimeInMillis()), + cal.getTime() + ); + + manager.schedule(req, this.getClass()); + } + + /** + * Clone options with base date attached to trigger. + * Used so that persisted objects know the last execution time. + * @param baseDateMillis base date represented in milliseconds + * @return new Options object with base time set in requestBaseDate. + */ + private Options getOptionsWithBaseDate(Options options, long baseDateMillis) { + JSONObject optionsDict = options.getDict(); + try { + JSONObject triggerDict = optionsDict.getJSONObject("trigger"); + triggerDict.put("requestBaseDate", baseDateMillis); + optionsDict.remove("trigger"); + optionsDict.put("trigger", triggerDict); + } catch (JSONException e) { + Log.e(TAG, "Unexpected error adding requestBaseDate to JSON structure: " + e.toString()); + } + return new Options(optionsDict); + } + + /** + * Send the application an event using our notification + * @param key key for our event in the app + * @param notification reference to the notification + */ + abstract public void dispatchAppEvent(String key, Notification notification); + + /** + * Check if the application is running. + * Should be developed in local class, which has access to things needed for this. + * @return whether or not app is running + */ + abstract public boolean checkAppRunning(); + + /** + * Wakeup the device. + * + * @param notification The notification used to wakeup the device. + * contains context and timeout. + */ + private void wakeUp(Notification notification) { + Context context = notification.getContext(); + Options options = notification.getOptions(); + String wakeLockTag = context.getApplicationInfo().name + ":LocalNotification"; + PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE); + + if (pm == null) + return; + + int level = PowerManager.FULL_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.ON_AFTER_RELEASE; + + PowerManager.WakeLock wakeLock = pm.newWakeLock(level, wakeLockTag); + + wakeLock.setReferenceCounted(false); + wakeLock.acquire(options.getWakeLockTimeout()); + } + +} diff --git a/src/android/notification/receiver/AbstractRestoreReceiver.java b/src/android/notification/receiver/AbstractRestoreReceiver.java index 753bc2502..85360ee55 100644 --- a/src/android/notification/receiver/AbstractRestoreReceiver.java +++ b/src/android/notification/receiver/AbstractRestoreReceiver.java @@ -46,7 +46,7 @@ * the alarms with the AlarmManager since these alarms are lost in case of * reboot. */ -abstract public class AbstractRestoreReceiver extends BroadcastReceiver { +abstract public class AbstractRestoreReceiver extends AbstractNotificationReceiver { /** * Called on device reboot. diff --git a/src/android/notification/receiver/AbstractTriggerReceiver.java b/src/android/notification/receiver/AbstractTriggerReceiver.java index 91d7e2a9c..c22547028 100644 --- a/src/android/notification/receiver/AbstractTriggerReceiver.java +++ b/src/android/notification/receiver/AbstractTriggerReceiver.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -35,7 +36,7 @@ * Abstract broadcast receiver for local notifications. Creates the * notification options and calls the event functions for further proceeding. */ -abstract public class AbstractTriggerReceiver extends BroadcastReceiver { +abstract public class AbstractTriggerReceiver extends AbstractNotificationReceiver { /** * Called when an alarm was triggered. diff --git a/src/android/notification/receiver/NotificationTrampolineActivity.java b/src/android/notification/receiver/NotificationTrampolineActivity.java new file mode 100644 index 000000000..34f5e9250 --- /dev/null +++ b/src/android/notification/receiver/NotificationTrampolineActivity.java @@ -0,0 +1,43 @@ +package de.appplant.cordova.plugin.notification.receiver; + +import androidx.appcompat.app.AppCompatActivity; + +import android.content.Intent; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; + + +/** + * To satitisfy the new android 12 requirement, the broadcast receiver used + * to handle click action on a notification, is replaced with a trampoline activity + * Note: to handle correctly the case where the application is running in background + * while the action is clicked, set + * + * in the config.xml file. + * If you don't add this line, the app is restarted + */ +public class NotificationTrampolineActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String packageName = this.getPackageName(); + Intent launchIntent = this.getPackageManager().getLaunchIntentForPackage(packageName); + String mainActivityClassName = launchIntent.getComponent().getClassName(); + Class mainActivityClass = null; + try { + mainActivityClass = Class.forName(mainActivityClassName); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + return; + } + + Intent intent = new Intent(this, mainActivityClass); + + // put activity from stack or instance it it doesn't exist + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } +} diff --git a/src/android/notification/trigger/DateTrigger.java b/src/android/notification/trigger/DateTrigger.java index 04055a079..c8a0116a1 100644 --- a/src/android/notification/trigger/DateTrigger.java +++ b/src/android/notification/trigger/DateTrigger.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License diff --git a/src/android/notification/trigger/IntervalTrigger.java b/src/android/notification/trigger/IntervalTrigger.java index 4aeb5e852..89ff8df2b 100644 --- a/src/android/notification/trigger/IntervalTrigger.java +++ b/src/android/notification/trigger/IntervalTrigger.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License diff --git a/src/android/notification/trigger/MatchTrigger.java b/src/android/notification/trigger/MatchTrigger.java index 9f753c615..696473250 100644 --- a/src/android/notification/trigger/MatchTrigger.java +++ b/src/android/notification/trigger/MatchTrigger.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -304,6 +305,7 @@ private boolean setDayOfWeek (Calendar cal) { return false; } + cal.set(Calendar.SECOND, 0); cal.set(DAY_OF_WEEK, specials.get(0)); if (matchers.get(3) != null && cal.get(Calendar.MONTH) != month) diff --git a/src/android/notification/util/AssetProvider.java b/src/android/notification/util/AssetProvider.java index 6e15825f9..569497fa2 100644 --- a/src/android/notification/util/AssetProvider.java +++ b/src/android/notification/util/AssetProvider.java @@ -19,8 +19,8 @@ Licensed to the Apache Software Foundation (ASF) under one package de.appplant.cordova.plugin.notification.util; -import android.support.v4.content.FileProvider; +import androidx.core.content.FileProvider; public class AssetProvider extends FileProvider { // Nothing to do here -} \ No newline at end of file +} diff --git a/src/android/notification/util/AssetUtil.java b/src/android/notification/util/AssetUtil.java index 2170860dd..0dd8d0638 100644 --- a/src/android/notification/util/AssetUtil.java +++ b/src/android/notification/util/AssetUtil.java @@ -2,6 +2,7 @@ * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 + * Contributor Bhumin Bhandari * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License @@ -248,10 +249,6 @@ private void copyFile(InputStream in, FileOutputStream out) { public int getResId(String resPath) { int resId = getResId(context.getResources(), resPath); - if (resId == 0) { - resId = getResId(Resources.getSystem(), resPath); - } - return resId; } @@ -317,7 +314,7 @@ private String getBaseName (String resPath) { */ private File getTmpFile () { // If random UUID is not be enough see - // https://github.com/LukePulverenti/cordova-plugin-local-notifications/blob/267170db14044cbeff6f4c3c62d9b766b7a1dd62/src/android/notification/AssetUtil.java#L255 + // https://github.com/LukePulverenti/cordova-plugin-local-notification/blob/267170db14044cbeff6f4c3c62d9b766b7a1dd62/src/android/notification/AssetUtil.java#L255 return getTmpFile(UUID.randomUUID().toString()); } diff --git a/src/android/notification/util/LaunchUtils.java b/src/android/notification/util/LaunchUtils.java new file mode 100644 index 000000000..19d3998c7 --- /dev/null +++ b/src/android/notification/util/LaunchUtils.java @@ -0,0 +1,67 @@ +package de.appplant.cordova.plugin.notification.util; + +import android.app.PendingIntent; +import android.app.TaskStackBuilder; +import android.content.Context; +import android.content.Intent; + +import static android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT; +import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; + +import java.util.Random; + +public final class LaunchUtils { + + private static int getIntentFlags() { + int FLAG_MUTABLE = 33554432; // don't use pendingIntent.FLAG_MUTABLE, use numeric value instead to be able to compile api < 31 + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (android.os.Build.VERSION.SDK_INT >= 31) { + flags |= FLAG_MUTABLE; + } + return flags; + } + + public static PendingIntent getBroadcastPendingIntent(Context context, + Intent intent, + int notificationId) { + return PendingIntent.getBroadcast(context, notificationId, intent, getIntentFlags()); + } + + public static PendingIntent getActivityPendingIntent(Context context, + Intent intent, + int notificationId) { + return PendingIntent.getActivity(context, notificationId, intent, getIntentFlags()); + } + + public static PendingIntent getTaskStackPendingIntent(Context context, + Intent intent, + int notificationId) { + TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context); + taskStackBuilder.addNextIntentWithParentStack(intent); + return taskStackBuilder.getPendingIntent(notificationId, getIntentFlags()); + } + + + /*** + * Launch main intent from package. + */ + public static void launchApp(Context context) { + String pkgName = context.getPackageName(); + + Intent intent = context + .getPackageManager() + .getLaunchIntentForPackage(pkgName); + + if (intent == null) + return; + + intent.addFlags( + FLAG_ACTIVITY_REORDER_TO_FRONT + | FLAG_ACTIVITY_SINGLE_TOP + | FLAG_ACTIVITY_NEW_TASK + ); + + context.startActivity(intent); + } +}