1/*
2 * Copyright (C) 2013 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.printspooler.model;
18
19import android.annotation.NonNull;
20import android.annotation.Nullable;
21import android.app.Notification;
22import android.app.Notification.Action;
23import android.app.Notification.InboxStyle;
24import android.app.NotificationManager;
25import android.app.PendingIntent;
26import android.content.BroadcastReceiver;
27import android.content.Context;
28import android.content.Intent;
29import android.graphics.drawable.BitmapDrawable;
30import android.graphics.drawable.Icon;
31import android.net.Uri;
32import android.os.AsyncTask;
33import android.os.PowerManager;
34import android.os.PowerManager.WakeLock;
35import android.os.RemoteException;
36import android.os.ServiceManager;
37import android.os.UserHandle;
38import android.print.IPrintManager;
39import android.print.PrintJobId;
40import android.print.PrintJobInfo;
41import android.print.PrintManager;
42import android.provider.Settings;
43import android.util.ArraySet;
44import android.util.Log;
45
46import com.android.printspooler.R;
47
48import java.util.ArrayList;
49import java.util.List;
50
51/**
52 * This class is responsible for updating the print notifications
53 * based on print job state transitions.
54 */
55final class NotificationController {
56    public static final boolean DEBUG = false;
57
58    public static final String LOG_TAG = "NotificationController";
59
60    private static final String INTENT_ACTION_CANCEL_PRINTJOB = "INTENT_ACTION_CANCEL_PRINTJOB";
61    private static final String INTENT_ACTION_RESTART_PRINTJOB = "INTENT_ACTION_RESTART_PRINTJOB";
62
63    private static final String EXTRA_PRINT_JOB_ID = "EXTRA_PRINT_JOB_ID";
64
65    private static final String PRINT_JOB_NOTIFICATION_GROUP_KEY = "PRINT_JOB_NOTIFICATIONS";
66    private static final String PRINT_JOB_NOTIFICATION_SUMMARY = "PRINT_JOB_NOTIFICATIONS_SUMMARY";
67
68    private final Context mContext;
69    private final NotificationManager mNotificationManager;
70
71    /**
72     * Mapping from printJobIds to their notification Ids.
73     */
74    private final ArraySet<PrintJobId> mNotifications;
75
76    public NotificationController(Context context) {
77        mContext = context;
78        mNotificationManager = (NotificationManager)
79                mContext.getSystemService(Context.NOTIFICATION_SERVICE);
80        mNotifications = new ArraySet<>(0);
81    }
82
83    public void onUpdateNotifications(List<PrintJobInfo> printJobs) {
84        List<PrintJobInfo> notifyPrintJobs = new ArrayList<>();
85
86        final int printJobCount = printJobs.size();
87        for (int i = 0; i < printJobCount; i++) {
88            PrintJobInfo printJob = printJobs.get(i);
89            if (shouldNotifyForState(printJob.getState())) {
90                notifyPrintJobs.add(printJob);
91            }
92        }
93
94        updateNotifications(notifyPrintJobs);
95    }
96
97    /**
98     * Update notifications for the given print jobs, remove all other notifications.
99     *
100     * @param printJobs The print job that we want to create notifications for.
101     */
102    private void updateNotifications(List<PrintJobInfo> printJobs) {
103        ArraySet<PrintJobId> removedPrintJobs = new ArraySet<>(mNotifications);
104
105        final int numPrintJobs = printJobs.size();
106
107        // Create summary notification
108        if (numPrintJobs > 1) {
109            createStackedNotification(printJobs);
110        } else {
111            mNotificationManager.cancel(PRINT_JOB_NOTIFICATION_SUMMARY, 0);
112        }
113
114        // Create per print job notification
115        for (int i = 0; i < numPrintJobs; i++) {
116            PrintJobInfo printJob = printJobs.get(i);
117            PrintJobId printJobId = printJob.getId();
118
119            removedPrintJobs.remove(printJobId);
120            mNotifications.add(printJobId);
121
122            createSimpleNotification(printJob);
123        }
124
125        // Remove notifications for print jobs that do not exist anymore
126        final int numRemovedPrintJobs = removedPrintJobs.size();
127        for (int i = 0; i < numRemovedPrintJobs; i++) {
128            PrintJobId removedPrintJob = removedPrintJobs.valueAt(i);
129
130            mNotificationManager.cancel(removedPrintJob.flattenToString(), 0);
131            mNotifications.remove(removedPrintJob);
132        }
133    }
134
135    private void createSimpleNotification(PrintJobInfo printJob) {
136        switch (printJob.getState()) {
137            case PrintJobInfo.STATE_FAILED: {
138                createFailedNotification(printJob);
139            } break;
140
141            case PrintJobInfo.STATE_BLOCKED: {
142                if (!printJob.isCancelling()) {
143                    createBlockedNotification(printJob);
144                } else {
145                    createCancellingNotification(printJob);
146                }
147            } break;
148
149            default: {
150                if (!printJob.isCancelling()) {
151                    createPrintingNotification(printJob);
152                } else {
153                    createCancellingNotification(printJob);
154                }
155            } break;
156        }
157    }
158
159    /**
160     * Create an {@link Action} that cancels a {@link PrintJobInfo print job}.
161     *
162     * @param printJob The {@link PrintJobInfo print job} to cancel
163     *
164     * @return An {@link Action} that will cancel a print job
165     */
166    private Action createCancelAction(PrintJobInfo printJob) {
167        return new Action.Builder(
168                Icon.createWithResource(mContext, R.drawable.stat_notify_cancelling),
169                mContext.getString(R.string.cancel), createCancelIntent(printJob)).build();
170    }
171
172    /**
173     * Create a notification for a print job.
174     *
175     * @param printJob the job the notification is for
176     * @param firstAction the first action shown in the notification
177     * @param secondAction the second action shown in the notification
178     */
179    private void createNotification(@NonNull PrintJobInfo printJob, @Nullable Action firstAction,
180            @Nullable Action secondAction) {
181        Notification.Builder builder = new Notification.Builder(mContext)
182                .setContentIntent(createContentIntent(printJob.getId()))
183                .setSmallIcon(computeNotificationIcon(printJob))
184                .setContentTitle(computeNotificationTitle(printJob))
185                .setWhen(System.currentTimeMillis())
186                .setOngoing(true)
187                .setShowWhen(true)
188                .setColor(mContext.getColor(
189                        com.android.internal.R.color.system_notification_accent_color))
190                .setGroup(PRINT_JOB_NOTIFICATION_GROUP_KEY);
191
192        if (firstAction != null) {
193            builder.addAction(firstAction);
194        }
195
196        if (secondAction != null) {
197            builder.addAction(secondAction);
198        }
199
200        if (printJob.getState() == PrintJobInfo.STATE_STARTED
201                || printJob.getState() == PrintJobInfo.STATE_QUEUED) {
202            float progress = printJob.getProgress();
203            if (progress >= 0) {
204                builder.setProgress(Integer.MAX_VALUE, (int) (Integer.MAX_VALUE * progress),
205                        false);
206            } else {
207                builder.setProgress(Integer.MAX_VALUE, 0, true);
208            }
209        }
210
211        CharSequence status = printJob.getStatus(mContext.getPackageManager());
212        if (status != null) {
213            builder.setContentText(status);
214        } else {
215            builder.setContentText(printJob.getPrinterName());
216        }
217
218        mNotificationManager.notify(printJob.getId().flattenToString(), 0, builder.build());
219    }
220
221    private void createPrintingNotification(PrintJobInfo printJob) {
222        createNotification(printJob, createCancelAction(printJob), null);
223    }
224
225    private void createFailedNotification(PrintJobInfo printJob) {
226        Action.Builder restartActionBuilder = new Action.Builder(
227                Icon.createWithResource(mContext, R.drawable.ic_restart),
228                mContext.getString(R.string.restart), createRestartIntent(printJob.getId()));
229
230        createNotification(printJob, createCancelAction(printJob), restartActionBuilder.build());
231    }
232
233    private void createBlockedNotification(PrintJobInfo printJob) {
234        createNotification(printJob, createCancelAction(printJob), null);
235    }
236
237    private void createCancellingNotification(PrintJobInfo printJob) {
238        createNotification(printJob, null, null);
239    }
240
241    private void createStackedNotification(List<PrintJobInfo> printJobs) {
242        Notification.Builder builder = new Notification.Builder(mContext)
243                .setContentIntent(createContentIntent(null))
244                .setWhen(System.currentTimeMillis())
245                .setOngoing(true)
246                .setShowWhen(true)
247                .setGroup(PRINT_JOB_NOTIFICATION_GROUP_KEY)
248                .setGroupSummary(true);
249
250        final int printJobCount = printJobs.size();
251
252        InboxStyle inboxStyle = new InboxStyle();
253
254        int icon = com.android.internal.R.drawable.ic_print;
255        for (int i = printJobCount - 1; i>= 0; i--) {
256            PrintJobInfo printJob = printJobs.get(i);
257
258            inboxStyle.addLine(computeNotificationTitle(printJob));
259
260            // if any print job is in an error state show an error icon for the summary
261            if (printJob.getState() == PrintJobInfo.STATE_FAILED
262                    || printJob.getState() == PrintJobInfo.STATE_BLOCKED) {
263                icon = com.android.internal.R.drawable.ic_print_error;
264            }
265        }
266
267        builder.setSmallIcon(icon);
268        builder.setLargeIcon(
269                ((BitmapDrawable) mContext.getResources().getDrawable(icon, null)).getBitmap());
270        builder.setNumber(printJobCount);
271        builder.setStyle(inboxStyle);
272        builder.setColor(mContext.getColor(
273                com.android.internal.R.color.system_notification_accent_color));
274
275        mNotificationManager.notify(PRINT_JOB_NOTIFICATION_SUMMARY, 0, builder.build());
276    }
277
278    private String computeNotificationTitle(PrintJobInfo printJob) {
279        switch (printJob.getState()) {
280            case PrintJobInfo.STATE_FAILED: {
281                return mContext.getString(R.string.failed_notification_title_template,
282                        printJob.getLabel());
283            }
284
285            case PrintJobInfo.STATE_BLOCKED: {
286                if (!printJob.isCancelling()) {
287                    return mContext.getString(R.string.blocked_notification_title_template,
288                            printJob.getLabel());
289                } else {
290                    return mContext.getString(
291                            R.string.cancelling_notification_title_template,
292                            printJob.getLabel());
293                }
294            }
295
296            default: {
297                if (!printJob.isCancelling()) {
298                    return mContext.getString(R.string.printing_notification_title_template,
299                            printJob.getLabel());
300                } else {
301                    return mContext.getString(
302                            R.string.cancelling_notification_title_template,
303                            printJob.getLabel());
304                }
305            }
306        }
307    }
308
309    private PendingIntent createContentIntent(PrintJobId printJobId) {
310        Intent intent = new Intent(Settings.ACTION_PRINT_SETTINGS);
311        if (printJobId != null) {
312            intent.putExtra(EXTRA_PRINT_JOB_ID, printJobId.flattenToString());
313            intent.setData(Uri.fromParts("printjob", printJobId.flattenToString(), null));
314        }
315        return PendingIntent.getActivity(mContext, 0, intent, 0);
316    }
317
318    private PendingIntent createCancelIntent(PrintJobInfo printJob) {
319        Intent intent = new Intent(mContext, NotificationBroadcastReceiver.class);
320        intent.setAction(INTENT_ACTION_CANCEL_PRINTJOB + "_" + printJob.getId().flattenToString());
321        intent.putExtra(EXTRA_PRINT_JOB_ID, printJob.getId());
322        return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_ONE_SHOT);
323    }
324
325    private PendingIntent createRestartIntent(PrintJobId printJobId) {
326        Intent intent = new Intent(mContext, NotificationBroadcastReceiver.class);
327        intent.setAction(INTENT_ACTION_RESTART_PRINTJOB + "_" + printJobId.flattenToString());
328        intent.putExtra(EXTRA_PRINT_JOB_ID, printJobId);
329        return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_ONE_SHOT);
330    }
331
332    private static boolean shouldNotifyForState(int state) {
333        switch (state) {
334            case PrintJobInfo.STATE_QUEUED:
335            case PrintJobInfo.STATE_STARTED:
336            case PrintJobInfo.STATE_FAILED:
337            case PrintJobInfo.STATE_COMPLETED:
338            case PrintJobInfo.STATE_CANCELED:
339            case PrintJobInfo.STATE_BLOCKED: {
340                return true;
341            }
342        }
343        return false;
344    }
345
346    private static int computeNotificationIcon(PrintJobInfo printJob) {
347        switch (printJob.getState()) {
348            case PrintJobInfo.STATE_FAILED:
349            case PrintJobInfo.STATE_BLOCKED: {
350                return com.android.internal.R.drawable.ic_print_error;
351            }
352            default: {
353                if (!printJob.isCancelling()) {
354                    return com.android.internal.R.drawable.ic_print;
355                } else {
356                    return R.drawable.stat_notify_cancelling;
357                }
358            }
359        }
360    }
361
362    public static final class NotificationBroadcastReceiver extends BroadcastReceiver {
363        @SuppressWarnings("hiding")
364        private static final String LOG_TAG = "NotificationBroadcastReceiver";
365
366        @Override
367        public void onReceive(Context context, Intent intent) {
368            String action = intent.getAction();
369            if (action != null && action.startsWith(INTENT_ACTION_CANCEL_PRINTJOB)) {
370                PrintJobId printJobId = intent.getExtras().getParcelable(EXTRA_PRINT_JOB_ID);
371                handleCancelPrintJob(context, printJobId);
372            } else if (action != null && action.startsWith(INTENT_ACTION_RESTART_PRINTJOB)) {
373                PrintJobId printJobId = intent.getExtras().getParcelable(EXTRA_PRINT_JOB_ID);
374                handleRestartPrintJob(context, printJobId);
375            }
376        }
377
378        private void handleCancelPrintJob(final Context context, final PrintJobId printJobId) {
379            if (DEBUG) {
380                Log.i(LOG_TAG, "handleCancelPrintJob() printJobId:" + printJobId);
381            }
382
383            // Call into the print manager service off the main thread since
384            // the print manager service may end up binding to the print spooler
385            // service which binding is handled on the main thread.
386            PowerManager powerManager = (PowerManager)
387                    context.getSystemService(Context.POWER_SERVICE);
388            final WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
389                    LOG_TAG);
390            wakeLock.acquire();
391
392            new AsyncTask<Void, Void, Void>() {
393                @Override
394                protected Void doInBackground(Void... params) {
395                    // We need to request the cancellation to be done by the print
396                    // manager service since it has to communicate with the managing
397                    // print service to request the cancellation. Also we need the
398                    // system service to be bound to the spooler since canceling a
399                    // print job will trigger persistence of current jobs which is
400                    // done on another thread and until it finishes the spooler has
401                    // to be kept around.
402                    try {
403                        IPrintManager printManager = IPrintManager.Stub.asInterface(
404                                ServiceManager.getService(Context.PRINT_SERVICE));
405                        printManager.cancelPrintJob(printJobId, PrintManager.APP_ID_ANY,
406                                UserHandle.myUserId());
407                    } catch (RemoteException re) {
408                        Log.i(LOG_TAG, "Error requesting print job cancellation", re);
409                    } finally {
410                        wakeLock.release();
411                    }
412                    return null;
413                }
414            }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null);
415        }
416
417        private void handleRestartPrintJob(final Context context, final PrintJobId printJobId) {
418            if (DEBUG) {
419                Log.i(LOG_TAG, "handleRestartPrintJob() printJobId:" + printJobId);
420            }
421
422            // Call into the print manager service off the main thread since
423            // the print manager service may end up binding to the print spooler
424            // service which binding is handled on the main thread.
425            PowerManager powerManager = (PowerManager)
426                    context.getSystemService(Context.POWER_SERVICE);
427            final WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
428                    LOG_TAG);
429            wakeLock.acquire();
430
431            new AsyncTask<Void, Void, Void>() {
432                @Override
433                protected Void doInBackground(Void... params) {
434                    // We need to request the restart to be done by the print manager
435                    // service since the latter must be bound to the spooler because
436                    // restarting a print job will trigger persistence of current jobs
437                    // which is done on another thread and until it finishes the spooler has
438                    // to be kept around.
439                    try {
440                        IPrintManager printManager = IPrintManager.Stub.asInterface(
441                                ServiceManager.getService(Context.PRINT_SERVICE));
442                        printManager.restartPrintJob(printJobId, PrintManager.APP_ID_ANY,
443                                UserHandle.myUserId());
444                    } catch (RemoteException re) {
445                        Log.i(LOG_TAG, "Error requesting print job restart", re);
446                    } finally {
447                        wakeLock.release();
448                    }
449                    return null;
450                }
451            }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null);
452        }
453    }
454}
455