blob: 3d88f20f0710d977be70623c5c3e1cf6e5b03e44 [file] [log] [blame]
Dan Sandler7647f1d2018-11-26 09:56:26 -05001/*
2 * Copyright (C) 2018 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
17package com.android.server.notification;
18
19import android.app.INotificationManager;
20import android.app.Notification;
21import android.app.NotificationChannel;
22import android.app.NotificationManager;
23import android.app.PendingIntent;
24import android.app.Person;
25import android.content.ComponentName;
26import android.content.Context;
27import android.content.Intent;
28import android.content.pm.ParceledListSlice;
29import android.content.res.Resources;
30import android.graphics.drawable.BitmapDrawable;
31import android.graphics.drawable.Drawable;
32import android.graphics.drawable.Icon;
33import android.net.Uri;
34import android.os.Binder;
35import android.os.RemoteException;
36import android.os.ShellCommand;
37import android.os.UserHandle;
38import android.text.TextUtils;
39import android.util.Slog;
40
41import java.io.PrintWriter;
42import java.net.URISyntaxException;
43import java.util.Collections;
44
45/**
46 * Implementation of `cmd notification` in NotificationManagerService.
47 */
48public class NotificationShellCmd extends ShellCommand {
49 private static final String USAGE =
50 "usage: cmd notification SUBCMD [args]\n\n"
51 + "SUBCMDs:\n"
52 + " allow_listener COMPONENT [user_id]\n"
53 + " disallow_listener COMPONENT [user_id]\n"
54 + " allow_assistant COMPONENT\n"
55 + " remove_assistant COMPONENT\n"
56 + " allow_dnd PACKAGE\n"
57 + " disallow_dnd PACKAGE\n"
58 + " suspend_package PACKAGE\n"
59 + " unsuspend_package PACKAGE\n"
60 + " post [--help | flags] TAG TEXT";
61
62 private static final String NOTIFY_USAGE =
63 "usage: cmd notification post [flags] <tag> <text>\n\n"
64 + "flags:\n"
65 + " -h|--help\n"
66 + " -v|--verbose\n"
67 + " -t|--title <text>\n"
68 + " -i|--icon <iconspec>\n"
69 + " -I|--large-icon <iconspec>\n"
70 + " -S|--style <style> [styleargs]\n"
71 + " -c|--content-intent <intentspec>\n"
72 + "\n"
73 + "styles: (default none)\n"
74 + " bigtext\n"
75 + " bigpicture --picture <iconspec>\n"
76 + " inbox --line <text> --line <text> ...\n"
77 + " messaging --conversation <title> --message <who>:<text> ...\n"
78 + " media\n"
79 + "\n"
80 + "an <iconspec> is one of\n"
81 + " file:///data/local/tmp/<img.png>\n"
82 + " content://<provider>/<path>\n"
83 + " @[<package>:]drawable/<img>\n"
84 + " data:base64,<B64DATA==>\n"
85 + "\n"
86 + "an <intentspec> is (broadcast|service|activity) <args>\n"
87 + " <args> are as described in `am start`";
88
89 public static final int NOTIFICATION_ID = 1138;
90 public static final String NOTIFICATION_PACKAGE = "com.android.shell";
91 public static final String CHANNEL_ID = "shellcmd";
92 public static final String CHANNEL_NAME = "Shell command";
93 public static final int CHANNEL_IMP = NotificationManager.IMPORTANCE_DEFAULT;
94
95 private final NotificationManagerService mDirectService;
96 private final INotificationManager mBinderService;
97
98 public NotificationShellCmd(NotificationManagerService service) {
99 mDirectService = service;
100 mBinderService = service.getBinderService();
101 }
102
103 @Override
104 public int onCommand(String cmd) {
105 if (cmd == null) {
106 return handleDefaultCommands(cmd);
107 }
108 final PrintWriter pw = getOutPrintWriter();
109 try {
110 switch (cmd.replace('-', '_')) {
111 case "allow_dnd": {
112 mBinderService.setNotificationPolicyAccessGranted(
113 getNextArgRequired(), true);
114 }
115 break;
116
117 case "disallow_dnd": {
118 mBinderService.setNotificationPolicyAccessGranted(
119 getNextArgRequired(), false);
120 }
121 break;
122 case "allow_listener": {
123 ComponentName cn = ComponentName.unflattenFromString(getNextArgRequired());
124 if (cn == null) {
125 pw.println("Invalid listener - must be a ComponentName");
126 return -1;
127 }
128 String userId = getNextArg();
129 if (userId == null) {
130 mBinderService.setNotificationListenerAccessGranted(cn, true);
131 } else {
132 mBinderService.setNotificationListenerAccessGrantedForUser(
133 cn, Integer.parseInt(userId), true);
134 }
135 }
136 break;
137 case "disallow_listener": {
138 ComponentName cn = ComponentName.unflattenFromString(getNextArgRequired());
139 if (cn == null) {
140 pw.println("Invalid listener - must be a ComponentName");
141 return -1;
142 }
143 String userId = getNextArg();
144 if (userId == null) {
145 mBinderService.setNotificationListenerAccessGranted(cn, false);
146 } else {
147 mBinderService.setNotificationListenerAccessGrantedForUser(
148 cn, Integer.parseInt(userId), false);
149 }
150 }
151 break;
152 case "allow_assistant": {
153 ComponentName cn = ComponentName.unflattenFromString(getNextArgRequired());
154 if (cn == null) {
155 pw.println("Invalid assistant - must be a ComponentName");
156 return -1;
157 }
158 mBinderService.setNotificationAssistantAccessGranted(cn, true);
159 }
160 break;
161 case "disallow_assistant": {
162 ComponentName cn = ComponentName.unflattenFromString(getNextArgRequired());
163 if (cn == null) {
164 pw.println("Invalid assistant - must be a ComponentName");
165 return -1;
166 }
167 mBinderService.setNotificationAssistantAccessGranted(cn, false);
168 }
169 break;
170 case "suspend_package": {
171 // only use for testing
172 mDirectService.simulatePackageSuspendBroadcast(true, getNextArgRequired());
173 }
174 break;
175 case "unsuspend_package": {
176 // only use for testing
177 mDirectService.simulatePackageSuspendBroadcast(false, getNextArgRequired());
178 }
179 break;
180 case "post":
181 case "notify":
182 doNotify(pw);
183 break;
184 default:
185 return handleDefaultCommands(cmd);
186 }
187 } catch (Exception e) {
188 pw.println("Error occurred. Check logcat for details. " + e.getMessage());
189 Slog.e(NotificationManagerService.TAG, "Error running shell command", e);
190 }
191 return 0;
192 }
193
194 void ensureChannel() throws RemoteException {
195 final int uid = Binder.getCallingUid();
196 final int userid = UserHandle.getCallingUserId();
197 final long token = Binder.clearCallingIdentity();
198 try {
199 if (mBinderService.getNotificationChannelForPackage(NOTIFICATION_PACKAGE,
200 uid, CHANNEL_ID, false) == null) {
201 final NotificationChannel chan = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME,
202 CHANNEL_IMP);
203 Slog.v(NotificationManagerService.TAG,
204 "creating shell channel for user " + userid + " uid " + uid + ": " + chan);
205 mBinderService.createNotificationChannelsForPackage(NOTIFICATION_PACKAGE, uid,
206 new ParceledListSlice<NotificationChannel>(
207 Collections.singletonList(chan)));
208 Slog.v(NotificationManagerService.TAG, "created channel: "
209 + mBinderService.getNotificationChannelForPackage(NOTIFICATION_PACKAGE,
210 uid, CHANNEL_ID, false));
211 }
212 } finally {
213 Binder.restoreCallingIdentity(token);
214 }
215 }
216
217 Icon parseIcon(Resources res, String encoded) throws IllegalArgumentException {
218 if (TextUtils.isEmpty(encoded)) return null;
219 if (encoded.startsWith("/")) {
220 encoded = "file://" + encoded;
221 }
222 if (encoded.startsWith("http:")
223 || encoded.startsWith("https:")
224 || encoded.startsWith("content:")
225 || encoded.startsWith("file:")
226 || encoded.startsWith("android.resource:")) {
227 Uri asUri = Uri.parse(encoded);
228 return Icon.createWithContentUri(asUri);
229 } else if (encoded.startsWith("@")) {
230 final int resid = res.getIdentifier(encoded.substring(1),
231 "drawable", "android");
232 if (resid != 0) {
233 return Icon.createWithResource(res, resid);
234 }
235 } else if (encoded.startsWith("data:")) {
236 encoded = encoded.substring(encoded.indexOf(',') + 1);
237 byte[] bits = android.util.Base64.decode(encoded, android.util.Base64.DEFAULT);
238 return Icon.createWithData(bits, 0, bits.length);
239 }
240 return null;
241 }
242
243 private int doNotify(PrintWriter pw) throws RemoteException, URISyntaxException {
244 final Context context = mDirectService.getContext();
245 final Resources res = context.getResources();
246 final Notification.Builder builder = new Notification.Builder(context, CHANNEL_ID);
247 String opt;
248
249 boolean verbose = false;
250 Notification.BigPictureStyle bigPictureStyle = null;
251 Notification.BigTextStyle bigTextStyle = null;
252 Notification.InboxStyle inboxStyle = null;
253 Notification.MediaStyle mediaStyle = null;
254 Notification.MessagingStyle messagingStyle = null;
255
256 Icon smallIcon = null;
257 while ((opt = getNextOption()) != null) {
258 boolean large = false;
259 switch (opt) {
260 case "-v":
261 case "--verbose":
262 verbose = true;
263 break;
264 case "-t":
265 case "--title":
266 case "title":
267 builder.setContentTitle(getNextArgRequired());
268 break;
269 case "-I":
270 case "--large-icon":
271 case "--largeicon":
272 case "largeicon":
273 case "large-icon":
274 large = true;
275 // fall through
276 case "-i":
277 case "--icon":
278 case "icon":
279 final String iconSpec = getNextArgRequired();
280 final Icon icon = parseIcon(res, iconSpec);
281 if (icon == null) {
282 pw.println("error: invalid icon: " + iconSpec);
283 return -1;
284 }
285 if (large) {
286 builder.setLargeIcon(icon);
287 large = false;
288 } else {
289 smallIcon = icon;
290 }
291 break;
292 case "-c":
293 case "--content-intent":
294 case "content-intent":
295 case "--intent":
296 case "intent":
297 String intentKind = null;
298 switch (peekNextArg()) {
299 case "broadcast":
300 case "service":
301 case "activity":
302 intentKind = getNextArg();
303 }
304 final Intent intent = Intent.parseCommandArgs(this, null);
305 if (intent.getData() == null) {
306 // force unique intents unless you know what you're doing
307 intent.setData(Uri.parse("xyz:" + System.currentTimeMillis()));
308 }
309 final PendingIntent pi;
310 if ("broadcast".equals(intentKind)) {
311 pi = PendingIntent.getBroadcastAsUser(
312 context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT,
313 UserHandle.CURRENT);
314 } else if ("service".equals(intentKind)) {
315 pi = PendingIntent.getService(
316 context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
317 } else {
318 pi = PendingIntent.getActivityAsUser(
319 context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, null,
320 UserHandle.CURRENT);
321 }
322 builder.setContentIntent(pi);
323 break;
324 case "-S":
325 case "--style":
326 final String styleSpec = getNextArgRequired().toLowerCase();
327 switch (styleSpec) {
328 case "bigtext":
329 bigTextStyle = new Notification.BigTextStyle();
330 builder.setStyle(bigTextStyle);
331 break;
332 case "bigpicture":
333 bigPictureStyle = new Notification.BigPictureStyle();
334 builder.setStyle(bigPictureStyle);
335 break;
336 case "inbox":
337 inboxStyle = new Notification.InboxStyle();
338 builder.setStyle(inboxStyle);
339 break;
340 case "messaging":
341 String name = "You";
342 if ("--user".equals(peekNextArg())) {
343 getNextArg();
344 name = getNextArgRequired();
345 }
346 messagingStyle = new Notification.MessagingStyle(
347 new Person.Builder().setName(name).build());
348 builder.setStyle(messagingStyle);
349 break;
350 case "media":
351 mediaStyle = new Notification.MediaStyle();
352 builder.setStyle(mediaStyle);
353 break;
354 default:
355 throw new IllegalArgumentException(
356 "unrecognized notification style: " + styleSpec);
357 }
358 break;
359 case "--bigText": case "--bigtext": case "--big-text":
360 if (bigTextStyle == null) {
361 throw new IllegalArgumentException("--bigtext requires --style bigtext");
362 }
363 bigTextStyle.bigText(getNextArgRequired());
364 break;
365 case "--picture":
366 if (bigPictureStyle == null) {
367 throw new IllegalArgumentException("--picture requires --style bigpicture");
368 }
369 final String pictureSpec = getNextArgRequired();
370 final Icon pictureAsIcon = parseIcon(res, pictureSpec);
371 if (pictureAsIcon == null) {
372 throw new IllegalArgumentException("bad picture spec: " + pictureSpec);
373 }
374 final Drawable d = pictureAsIcon.loadDrawable(context);
375 if (d instanceof BitmapDrawable) {
376 bigPictureStyle.bigPicture(((BitmapDrawable) d).getBitmap());
377 } else {
378 throw new IllegalArgumentException("not a bitmap: " + pictureSpec);
379 }
380 break;
381 case "--line":
382 if (inboxStyle == null) {
383 throw new IllegalArgumentException("--line requires --style inbox");
384 }
385 inboxStyle.addLine(getNextArgRequired());
386 break;
387 case "--message":
388 if (messagingStyle == null) {
389 throw new IllegalArgumentException(
390 "--message requires --style messaging");
391 }
392 String arg = getNextArgRequired();
393 String[] parts = arg.split(":", 2);
394 if (parts.length > 1) {
395 messagingStyle.addMessage(parts[1], System.currentTimeMillis(),
396 parts[0]);
397 } else {
398 messagingStyle.addMessage(parts[0], System.currentTimeMillis(),
399 new String[]{
400 messagingStyle.getUserDisplayName().toString(),
401 "Them"
402 }[messagingStyle.getMessages().size() % 2]);
403 }
404 break;
405 case "--conversation":
406 if (messagingStyle == null) {
407 throw new IllegalArgumentException(
408 "--conversation requires --style messaging");
409 }
410 messagingStyle.setConversationTitle(getNextArgRequired());
411 break;
412 case "-h":
413 case "--help":
414 case "--wtf":
415 default:
416 pw.println(NOTIFY_USAGE);
417 return 0;
418 }
419 }
420
421 final String tag = getNextArg();
422 final String text = getNextArg();
423 if (tag == null || text == null) {
424 pw.println(NOTIFY_USAGE);
425 return -1;
426 }
427
428 builder.setContentText(text);
429
430 if (smallIcon == null) {
431 // uh oh, let's substitute something
432 builder.setSmallIcon(com.android.internal.R.drawable.stat_notify_chat);
433 } else {
434 builder.setSmallIcon(smallIcon);
435 }
436
437 ensureChannel();
438
439 final Notification n = builder.build();
440 pw.println("posting:\n " + n);
441 Slog.v("NotificationManager", "posting: " + n);
442
443 final int userId = UserHandle.getCallingUserId();
444 final long token = Binder.clearCallingIdentity();
445 try {
446 mBinderService.enqueueNotificationWithTag(
447 NOTIFICATION_PACKAGE, "android",
448 tag, NOTIFICATION_ID,
449 n, userId);
450 } finally {
451 Binder.restoreCallingIdentity(token);
452 }
453
454 if (verbose) {
455 NotificationRecord nr = mDirectService.findNotificationLocked(
456 NOTIFICATION_PACKAGE, tag, NOTIFICATION_ID, userId);
457 for (int tries = 3; tries-- > 0; ) {
458 if (nr != null) break;
459 try {
460 pw.println("waiting for notification to post...");
461 Thread.sleep(500);
462 } catch (InterruptedException e) {
463 }
464 nr = mDirectService.findNotificationLocked(
465 NOTIFICATION_PACKAGE, tag, NOTIFICATION_ID, userId);
466 }
467 if (nr == null) {
468 pw.println("warning: couldn't find notification after enqueueing");
469 } else {
470 pw.println("posted: ");
471 nr.dump(pw, " ", context, false);
472 }
473 }
474
475 return 0;
476 }
477
478 @Override
479 public void onHelp() {
480 getOutPrintWriter().println(USAGE);
481 }
482}
483