1 /*
2  * Copyright (C) 2012 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.core.app;
18 
19 import android.app.Notification;
20 import android.app.PendingIntent;
21 import android.os.Bundle;
22 import android.os.Parcelable;
23 import android.util.Log;
24 import android.util.SparseArray;
25 
26 import androidx.core.graphics.drawable.IconCompat;
27 
28 import java.lang.reflect.Field;
29 import java.util.ArrayList;
30 import java.util.Arrays;
31 import java.util.HashSet;
32 import java.util.List;
33 import java.util.Set;
34 
35 class NotificationCompatJellybean {
36     public static final String TAG = "NotificationCompat";
37 
38     // Extras keys used for Jellybean SDK and above.
39     static final String EXTRA_DATA_ONLY_REMOTE_INPUTS = "android.support.dataRemoteInputs";
40     static final String EXTRA_ALLOW_GENERATED_REPLIES = "android.support.allowGeneratedReplies";
41 
42     // Bundle keys for storing action fields in a bundle
43     private static final String KEY_ICON = "icon";
44     private static final String KEY_TITLE = "title";
45     private static final String KEY_ACTION_INTENT = "actionIntent";
46     private static final String KEY_EXTRAS = "extras";
47     private static final String KEY_REMOTE_INPUTS = "remoteInputs";
48     private static final String KEY_DATA_ONLY_REMOTE_INPUTS = "dataOnlyRemoteInputs";
49     private static final String KEY_RESULT_KEY = "resultKey";
50     private static final String KEY_LABEL = "label";
51     private static final String KEY_CHOICES = "choices";
52     private static final String KEY_ALLOW_FREE_FORM_INPUT = "allowFreeFormInput";
53     private static final String KEY_ALLOWED_DATA_TYPES = "allowedDataTypes";
54     private static final String KEY_SEMANTIC_ACTION = "semanticAction";
55     private static final String KEY_SHOWS_USER_INTERFACE = "showsUserInterface";
56 
57     private static final Object sExtrasLock = new Object();
58     private static Field sExtrasField;
59     private static boolean sExtrasFieldAccessFailed;
60 
61     private static final Object sActionsLock = new Object();
62     private static Field sActionsField;
63     private static Field sActionIconField;
64     private static Field sActionTitleField;
65     private static Field sActionIntentField;
66     private static boolean sActionsAccessFailed;
67 
68     /** Return an SparseArray for action extras or null if none was needed. */
buildActionExtrasMap(List<Bundle> actionExtrasList)69     public static SparseArray<Bundle> buildActionExtrasMap(List<Bundle> actionExtrasList) {
70         SparseArray<Bundle> actionExtrasMap = null;
71         for (int i = 0, count = actionExtrasList.size(); i < count; i++) {
72             Bundle actionExtras = actionExtrasList.get(i);
73             if (actionExtras != null) {
74                 if (actionExtrasMap == null) {
75                     actionExtrasMap = new SparseArray<Bundle>();
76                 }
77                 actionExtrasMap.put(i, actionExtras);
78             }
79         }
80         return actionExtrasMap;
81     }
82 
83     /**
84      * Get the extras Bundle from a notification using reflection. Extras were present in
85      * Jellybean notifications, but the field was private until KitKat.
86      */
getExtras(Notification notif)87     public static Bundle getExtras(Notification notif) {
88         synchronized (sExtrasLock) {
89             if (sExtrasFieldAccessFailed) {
90                 return null;
91             }
92             try {
93                 if (sExtrasField == null) {
94                     Field extrasField = Notification.class.getDeclaredField("extras");
95                     if (!Bundle.class.isAssignableFrom(extrasField.getType())) {
96                         Log.e(TAG, "Notification.extras field is not of type Bundle");
97                         sExtrasFieldAccessFailed = true;
98                         return null;
99                     }
100                     extrasField.setAccessible(true);
101                     sExtrasField = extrasField;
102                 }
103                 Bundle extras = (Bundle) sExtrasField.get(notif);
104                 if (extras == null) {
105                     extras = new Bundle();
106                     sExtrasField.set(notif, extras);
107                 }
108                 return extras;
109             } catch (IllegalAccessException e) {
110                 Log.e(TAG, "Unable to access notification extras", e);
111             } catch (NoSuchFieldException e) {
112                 Log.e(TAG, "Unable to access notification extras", e);
113             }
114             sExtrasFieldAccessFailed = true;
115             return null;
116         }
117     }
118 
readAction(int icon, CharSequence title, PendingIntent actionIntent, Bundle extras)119     public static NotificationCompat.Action readAction(int icon, CharSequence title,
120             PendingIntent actionIntent, Bundle extras) {
121         RemoteInput[] remoteInputs = null;
122         RemoteInput[] dataOnlyRemoteInputs = null;
123         boolean allowGeneratedReplies = false;
124         if (extras != null) {
125             remoteInputs = fromBundleArray(
126                     getBundleArrayFromBundle(extras,
127                             NotificationCompatExtras.EXTRA_REMOTE_INPUTS));
128             dataOnlyRemoteInputs = fromBundleArray(
129                     getBundleArrayFromBundle(extras, EXTRA_DATA_ONLY_REMOTE_INPUTS));
130             allowGeneratedReplies = extras.getBoolean(EXTRA_ALLOW_GENERATED_REPLIES);
131         }
132         return new NotificationCompat.Action(icon, title, actionIntent, extras, remoteInputs,
133                 dataOnlyRemoteInputs, allowGeneratedReplies,
134                 NotificationCompat.Action.SEMANTIC_ACTION_NONE, true, false /* isContextual */,
135                 false /* authRequired */);
136     }
137 
writeActionAndGetExtras( Notification.Builder builder, NotificationCompat.Action action)138     public static Bundle writeActionAndGetExtras(
139             Notification.Builder builder, NotificationCompat.Action action) {
140         IconCompat iconCompat = action.getIconCompat();
141         builder.addAction(
142                 iconCompat != null ? iconCompat.getResId() : 0,
143                 action.getTitle(),
144                 action.getActionIntent());
145         Bundle actionExtras = new Bundle(action.getExtras());
146         if (action.getRemoteInputs() != null) {
147             actionExtras.putParcelableArray(NotificationCompatExtras.EXTRA_REMOTE_INPUTS,
148                     toBundleArray(action.getRemoteInputs()));
149         }
150         if (action.getDataOnlyRemoteInputs() != null) {
151             actionExtras.putParcelableArray(EXTRA_DATA_ONLY_REMOTE_INPUTS,
152                     toBundleArray(action.getDataOnlyRemoteInputs()));
153         }
154         actionExtras.putBoolean(EXTRA_ALLOW_GENERATED_REPLIES,
155                 action.getAllowGeneratedReplies());
156         return actionExtras;
157     }
158 
getActionCount(Notification notif)159     public static int getActionCount(Notification notif) {
160         synchronized (sActionsLock) {
161             Object[] actionObjects = getActionObjectsLocked(notif);
162             return actionObjects != null ? actionObjects.length : 0;
163         }
164     }
165 
166     @SuppressWarnings("deprecation")
getAction(Notification notif, int actionIndex)167     public static NotificationCompat.Action getAction(Notification notif, int actionIndex) {
168         synchronized (sActionsLock) {
169             try {
170                 Object[] actionObjects = getActionObjectsLocked(notif);
171                 if (actionObjects != null) {
172                     Object actionObject = actionObjects[actionIndex];
173                     Bundle actionExtras = null;
174                     Bundle extras = getExtras(notif);
175                     if (extras != null) {
176                         SparseArray<Bundle> actionExtrasMap = extras.getSparseParcelableArray(
177                                 NotificationCompatExtras.EXTRA_ACTION_EXTRAS);
178                         if (actionExtrasMap != null) {
179                             actionExtras = actionExtrasMap.get(actionIndex);
180                         }
181                     }
182                     return readAction(sActionIconField.getInt(actionObject),
183                             (CharSequence) sActionTitleField.get(actionObject),
184                             (PendingIntent) sActionIntentField.get(actionObject),
185                             actionExtras);
186                 }
187             } catch (IllegalAccessException e) {
188                 Log.e(TAG, "Unable to access notification actions", e);
189                 sActionsAccessFailed = true;
190             }
191         }
192         return null;
193     }
194 
getActionObjectsLocked(Notification notif)195     private static Object[] getActionObjectsLocked(Notification notif) {
196         synchronized (sActionsLock) {
197             if (!ensureActionReflectionReadyLocked()) {
198                 return null;
199             }
200             try {
201                 return (Object[]) sActionsField.get(notif);
202             } catch (IllegalAccessException e) {
203                 Log.e(TAG, "Unable to access notification actions", e);
204                 sActionsAccessFailed = true;
205                 return null;
206             }
207         }
208     }
209 
210     @SuppressWarnings("LiteralClassName")
ensureActionReflectionReadyLocked()211     private static boolean ensureActionReflectionReadyLocked() {
212         if (sActionsAccessFailed) {
213             return false;
214         }
215         try {
216             if (sActionsField == null) {
217                 Class<?> sActionClass = Class.forName("android.app.Notification$Action");
218                 sActionIconField = sActionClass.getDeclaredField("icon");
219                 sActionTitleField = sActionClass.getDeclaredField("title");
220                 sActionIntentField = sActionClass.getDeclaredField("actionIntent");
221                 sActionsField = Notification.class.getDeclaredField("actions");
222                 sActionsField.setAccessible(true);
223             }
224         } catch (ClassNotFoundException e) {
225             Log.e(TAG, "Unable to access notification actions", e);
226             sActionsAccessFailed = true;
227         } catch (NoSuchFieldException e) {
228             Log.e(TAG, "Unable to access notification actions", e);
229             sActionsAccessFailed = true;
230         }
231         return !sActionsAccessFailed;
232     }
233 
234     @SuppressWarnings("deprecation")
getActionFromBundle(Bundle bundle)235     static NotificationCompat.Action getActionFromBundle(Bundle bundle) {
236         Bundle extras = bundle.getBundle(KEY_EXTRAS);
237         boolean allowGeneratedReplies = false;
238         if (extras != null) {
239             allowGeneratedReplies = extras.getBoolean(EXTRA_ALLOW_GENERATED_REPLIES, false);
240         }
241         return new NotificationCompat.Action(
242                 bundle.getInt(KEY_ICON),
243                 bundle.getCharSequence(KEY_TITLE),
244                 bundle.<PendingIntent>getParcelable(KEY_ACTION_INTENT),
245                 bundle.getBundle(KEY_EXTRAS),
246                 fromBundleArray(getBundleArrayFromBundle(bundle, KEY_REMOTE_INPUTS)),
247                 fromBundleArray(getBundleArrayFromBundle(bundle, KEY_DATA_ONLY_REMOTE_INPUTS)),
248                 allowGeneratedReplies,
249                 bundle.getInt(KEY_SEMANTIC_ACTION),
250                 bundle.getBoolean(KEY_SHOWS_USER_INTERFACE),
251                 false /* is_contextual is only supported for Q+ devices */,
252                 false /* authRequired */);
253     }
254 
getBundleForAction(NotificationCompat.Action action)255     static Bundle getBundleForAction(NotificationCompat.Action action) {
256         Bundle bundle = new Bundle();
257         IconCompat icon = action.getIconCompat();
258         bundle.putInt(KEY_ICON, icon != null ? icon.getResId() : 0);
259         bundle.putCharSequence(KEY_TITLE, action.getTitle());
260         bundle.putParcelable(KEY_ACTION_INTENT, action.getActionIntent());
261         Bundle actionExtras;
262         if (action.getExtras() != null) {
263             actionExtras = new Bundle(action.getExtras());
264         } else {
265             actionExtras = new Bundle();
266         }
267         actionExtras.putBoolean(NotificationCompatJellybean.EXTRA_ALLOW_GENERATED_REPLIES,
268                 action.getAllowGeneratedReplies());
269         bundle.putBundle(KEY_EXTRAS, actionExtras);
270         bundle.putParcelableArray(KEY_REMOTE_INPUTS, toBundleArray(action.getRemoteInputs()));
271         bundle.putBoolean(KEY_SHOWS_USER_INTERFACE, action.getShowsUserInterface());
272         bundle.putInt(KEY_SEMANTIC_ACTION, action.getSemanticAction());
273         return bundle;
274     }
275 
276 
fromBundle(Bundle data)277     private static RemoteInput fromBundle(Bundle data) {
278         ArrayList<String> allowedDataTypesAsList = data.getStringArrayList(KEY_ALLOWED_DATA_TYPES);
279         Set<String> allowedDataTypes = new HashSet<>();
280         if (allowedDataTypesAsList != null) {
281             for (String type : allowedDataTypesAsList) {
282                 allowedDataTypes.add(type);
283             }
284         }
285         return new RemoteInput(data.getString(KEY_RESULT_KEY),
286                 data.getCharSequence(KEY_LABEL),
287                 data.getCharSequenceArray(KEY_CHOICES),
288                 data.getBoolean(KEY_ALLOW_FREE_FORM_INPUT),
289                 RemoteInput.EDIT_CHOICES_BEFORE_SENDING_AUTO, // Tap-to-edit is only supported on Q+
290                 data.getBundle(KEY_EXTRAS),
291                 allowedDataTypes);
292     }
293 
toBundle(RemoteInput remoteInput)294     private static Bundle toBundle(RemoteInput remoteInput) {
295         Bundle data = new Bundle();
296         data.putString(KEY_RESULT_KEY, remoteInput.getResultKey());
297         data.putCharSequence(KEY_LABEL, remoteInput.getLabel());
298         data.putCharSequenceArray(KEY_CHOICES, remoteInput.getChoices());
299         data.putBoolean(KEY_ALLOW_FREE_FORM_INPUT, remoteInput.getAllowFreeFormInput());
300         data.putBundle(KEY_EXTRAS, remoteInput.getExtras());
301 
302         Set<String> allowedDataTypes = remoteInput.getAllowedDataTypes();
303         if (allowedDataTypes != null && !allowedDataTypes.isEmpty()) {
304             ArrayList<String> allowedDataTypesAsList = new ArrayList<>(allowedDataTypes.size());
305             for (String type : allowedDataTypes) {
306                 allowedDataTypesAsList.add(type);
307             }
308             data.putStringArrayList(KEY_ALLOWED_DATA_TYPES, allowedDataTypesAsList);
309         }
310         return data;
311     }
312 
fromBundleArray(Bundle[] bundles)313     private static RemoteInput[] fromBundleArray(Bundle[] bundles) {
314         if (bundles == null) {
315             return null;
316         }
317         RemoteInput[] remoteInputs = new RemoteInput[bundles.length];
318         for (int i = 0; i < bundles.length; i++) {
319             remoteInputs[i] = fromBundle(bundles[i]);
320         }
321         return remoteInputs;
322     }
323 
toBundleArray(RemoteInput[] remoteInputs)324     private static Bundle[] toBundleArray(RemoteInput[] remoteInputs) {
325         if (remoteInputs == null) {
326             return null;
327         }
328         Bundle[] bundles = new Bundle[remoteInputs.length];
329         for (int i = 0; i < remoteInputs.length; i++) {
330             bundles[i] = toBundle(remoteInputs[i]);
331         }
332         return bundles;
333     }
334 
335     /**
336      * Get an array of Bundle objects from a parcelable array field in a bundle.
337      * Update the bundle to have a typed array so fetches in the future don't need
338      * to do an array copy.
339      */
340     @SuppressWarnings("deprecation")
getBundleArrayFromBundle(Bundle bundle, String key)341     private static Bundle[] getBundleArrayFromBundle(Bundle bundle, String key) {
342         Parcelable[] array = bundle.getParcelableArray(key);
343         if (array instanceof Bundle[] || array == null) {
344             return (Bundle[]) array;
345         }
346         Bundle[] typedArray = Arrays.copyOf(array, array.length,
347                 Bundle[].class);
348         bundle.putParcelableArray(key, typedArray);
349         return typedArray;
350     }
351 
NotificationCompatJellybean()352     private NotificationCompatJellybean() {
353     }
354 }
355