/* * Apache 2.0 License * * Copyright (c) Sebastian Katzer 2017 * * This file contains Original Code and/or Modifications of Original Code * as defined in and that are subject to the Apache License * Version 2.0 (the 'License'). You may not use this file except in * compliance with the License. Please obtain a copy of the License at * http://opensource.org/licenses/Apache-2.0/ and read it before using this * file. * * The Original Code and all software distributed under the License are * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES, * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT. * Please see the License for the specific language governing rights and * limitations under the License. */ package de.appplant.cordova.plugin.notification; import android.app.AlarmManager; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; 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.Log; import android.util.SparseArray; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Set; 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_MAX; import static android.support.v4.app.NotificationManagerCompat.IMPORTANCE_MIN; /** * Wrapper class around OS notification class. Handles basic operations * like show, delete, cancel for a single local notification instance. */ public final class Notification { // Used to differ notifications by their life cycle state public enum Type { ALL, SCHEDULED, TRIGGERED } // Extra key for the id public static final String EXTRA_ID = "NOTIFICATION_ID"; // Extra key for the update flag public static final String EXTRA_UPDATE = "NOTIFICATION_UPDATE"; // Key for private preferences static final String PREF_KEY_ID = "NOTIFICATION_ID"; // Key for private preferences private static final String PREF_KEY_PID = "NOTIFICATION_PID"; // Cache for the builder instances private static SparseArray cache = null; // Application context passed by constructor private final Context context; // Notification options passed by JS private final Options options; // Builder with full configuration private final NotificationCompat.Builder builder; /** * Constructor * * @param context Application context. * @param options Parsed notification options. * @param builder Pre-configured notification builder. */ Notification (Context context, Options options, NotificationCompat.Builder builder) { this.context = context; this.options = options; this.builder = builder; } /** * Constructor * * @param context Application context. * @param options Parsed notification options. */ public Notification(Context context, Options options) { this.context = context; this.options = options; this.builder = null; } /** * Get application context. */ public Context getContext() { return context; } /** * Get notification options. */ public Options getOptions() { return options; } /** * Get notification ID. */ public int getId() { return options.getId(); } /** * If it's a repeating notification. */ public boolean isRepeating() { return getOptions().getTrigger().has("every"); } /** * If the notifications priority is high or above. */ public boolean isHighPrio() { return getOptions().getPrio() >= PRIORITY_HIGH; } /** * Notification type can be one of triggered or scheduled. */ public Type getType() { Manager mgr = Manager.getInstance(context); StatusBarNotification[] toasts = mgr.getActiveNotifications(); int id = getId(); for (StatusBarNotification toast : toasts) { if (toast.getId() == id) { return Type.TRIGGERED; } } return Type.SCHEDULED; } /** * Schedule the local notification. * * @param request Set of notification options. * @param receiver Receiver to handle the trigger event. */ void schedule(Request request, Class receiver) { List> intents = new ArrayList>(); Set ids = new ArraySet(); AlarmManager mgr = getAlarmMgr(); cancelScheduledAlarms(); do { Date date = request.getTriggerDate(); Log.d("local-notification", "Next trigger at: " + date); if (date == null) continue; Intent intent = new Intent(context, receiver) .setAction(PREF_KEY_ID + request.getIdentifier()) .putExtra(Notification.EXTRA_ID, options.getId()) .putExtra(Request.EXTRA_OCCURRENCE, request.getOccurrence()); ids.add(intent.getAction()); intents.add(new Pair(date, intent)); } while (request.moveNext()); if (intents.isEmpty()) { unpersist(); return; } persist(ids); if (!options.isInfiniteTrigger()) { Intent last = intents.get(intents.size() - 1).second; last.putExtra(Request.EXTRA_LAST, true); } for (Pair pair : intents) { Date date = pair.first; long time = date.getTime(); Intent intent = pair.second; if (!date.after(new Date()) && trigger(intent, receiver)) continue; PendingIntent pi = PendingIntent.getBroadcast( context, 0, intent, FLAG_CANCEL_CURRENT); try { switch (options.getPrio()) { case IMPORTANCE_MIN: mgr.setExact(RTC, time, pi); break; case IMPORTANCE_MAX: if (SDK_INT >= M) { mgr.setExactAndAllowWhileIdle(RTC_WAKEUP, time, pi); } else { mgr.setExact(RTC, time, pi); } break; default: mgr.setExact(RTC_WAKEUP, time, pi); break; } } catch (Exception ignore) { // Samsung devices have a known bug where a 500 alarms limit // can crash the app } } } /** * Trigger local notification specified by options. * * @param intent The intent to broadcast. * @param cls The broadcast class. * * @return false if the receiver could not be invoked. */ private boolean trigger (Intent intent, Class cls) { BroadcastReceiver receiver; try { receiver = (BroadcastReceiver) cls.newInstance(); } catch (InstantiationException e) { return false; } catch (IllegalAccessException e) { return false; } receiver.onReceive(context, intent); return true; } /** * Clear the local notification without canceling repeating alarms. */ public void clear() { getNotMgr().cancel(getId()); if (isRepeating()) return; unpersist(); } /** * Cancel the local notification. */ public void cancel() { cancelScheduledAlarms(); unpersist(); getNotMgr().cancel(getId()); clearCache(); } /** * Cancel the scheduled future local notification. * * Create an intent that looks similar, to the one that was registered * using schedule. Making sure the notification id in the action is the * same. Now we can search for such an intent using the 'getService' * method and cancel it. */ private void cancelScheduledAlarms() { SharedPreferences prefs = getPrefs(PREF_KEY_PID); String id = options.getIdentifier(); Set actions = prefs.getStringSet(id, null); if (actions == null) return; for (String action : actions) { Intent intent = new Intent(action); PendingIntent pi = PendingIntent.getBroadcast( context, 0, intent, 0); if (pi != null) { getAlarmMgr().cancel(pi); } } } /** * Present the local notification to user. */ public void show() { if (builder == null) return; if (options.showChronometer()) { cacheBuilder(); } grantPermissionToPlaySoundFromExternal(); getNotMgr().notify(getId(), builder.build()); } /** * Update the notification properties. * * @param updates The properties to update. * @param receiver Receiver to handle the trigger event. */ void update (JSONObject updates, Class receiver) { mergeJSONObjects(updates); persist(null); if (getType() != Type.TRIGGERED) return; Intent intent = new Intent(context, receiver) .setAction(PREF_KEY_ID + options.getId()) .putExtra(Notification.EXTRA_ID, options.getId()) .putExtra(Notification.EXTRA_UPDATE, true); trigger(intent, receiver); } /** * Encode options to JSON. */ public String toString() { JSONObject dict = options.getDict(); JSONObject json = new JSONObject(); try { json = new JSONObject(dict.toString()); } catch (JSONException e) { e.printStackTrace(); } return json.toString(); } /** * Persist the information of this notification to the Android Shared * Preferences. This will allow the application to restore the notification * upon device reboot, app restart, retrieve notifications, aso. * * @param ids List of intent actions to persist. */ private void persist (Set ids) { String id = options.getIdentifier(); SharedPreferences.Editor editor; editor = getPrefs(PREF_KEY_ID).edit(); editor.putString(id, options.toString()); editor.apply(); if (ids == null) return; editor = getPrefs(PREF_KEY_PID).edit(); editor.putStringSet(id, ids); editor.apply(); } /** * Remove the notification from the Android shared Preferences. */ private void unpersist () { String[] keys = { PREF_KEY_ID, PREF_KEY_PID }; String id = options.getIdentifier(); SharedPreferences.Editor editor; for (String key : keys) { editor = getPrefs(key).edit(); editor.remove(id); editor.apply(); } } /** * Since Android 7 the app will crash if an external process has no * permission to access the referenced sound file. */ private void grantPermissionToPlaySoundFromExternal() { if (builder == null) return; String sound = builder.getExtras().getString(Options.EXTRA_SOUND); Uri soundUri = Uri.parse(sound); context.grantUriPermission( "com.android.systemui", soundUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); } /** * Merge two JSON objects. */ private void mergeJSONObjects (JSONObject updates) { JSONObject dict = options.getDict(); Iterator it = updates.keys(); while (it.hasNext()) { try { String key = (String)it.next(); dict.put(key, updates.opt(key)); } catch (JSONException e) { e.printStackTrace(); } } } /** * Caches the builder instance so it can be used later. */ private void cacheBuilder() { if (cache == null) { cache = new SparseArray(); } cache.put(getId(), builder); } /** * Find the cached builder instance. * * @param key The key under where to look for the builder. * * @return null if no builder instance could be found. */ static NotificationCompat.Builder getCachedBuilder (int key) { return (cache != null) ? cache.get(key) : null; } /** * Caches the builder instance so it can be used later. */ private void clearCache () { if (cache != null) { cache.delete(getId()); } } /** * Shared private preferences for the application. */ private SharedPreferences getPrefs (String key) { return context.getSharedPreferences(key, Context.MODE_PRIVATE); } /** * Notification manager for the application. */ private NotificationManager getNotMgr () { return (NotificationManager) context .getSystemService(Context.NOTIFICATION_SERVICE); } /** * Alarm manager for the application. */ private AlarmManager getAlarmMgr () { return (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); } }