1/*
2 * Copyright (C) 2016 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.deskclock.data;
18
19import android.annotation.TargetApi;
20import android.app.AlarmManager;
21import android.app.Notification;
22import android.app.PendingIntent;
23import android.content.Context;
24import android.content.Intent;
25import android.content.res.Resources;
26import android.os.Build;
27import android.os.SystemClock;
28import android.support.annotation.DrawableRes;
29import android.support.v4.app.NotificationCompat;
30import android.support.v4.content.ContextCompat;
31import android.text.TextUtils;
32import android.widget.RemoteViews;
33
34import com.android.deskclock.AlarmUtils;
35import com.android.deskclock.R;
36import com.android.deskclock.Utils;
37import com.android.deskclock.events.Events;
38import com.android.deskclock.timer.ExpiredTimersActivity;
39import com.android.deskclock.timer.TimerService;
40
41import java.util.ArrayList;
42import java.util.List;
43
44import static android.support.v4.app.NotificationCompat.Action;
45import static android.support.v4.app.NotificationCompat.Builder;
46import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
47import static android.text.format.DateUtils.SECOND_IN_MILLIS;
48
49/**
50 * Builds notifications to reflect the latest state of the timers.
51 */
52class TimerNotificationBuilder {
53
54    private static final int REQUEST_CODE_UPCOMING = 0;
55    private static final int REQUEST_CODE_MISSING = 1;
56
57    public Notification build(Context context, NotificationModel nm, List<Timer> unexpired) {
58        final Timer timer = unexpired.get(0);
59        final int count = unexpired.size();
60
61        // Compute some values required below.
62        final boolean running = timer.isRunning();
63        final Resources res = context.getResources();
64
65        final long base = getChronometerBase(timer);
66        final String pname = context.getPackageName();
67
68        final List<Action> actions = new ArrayList<>(2);
69
70        final CharSequence stateText;
71        if (count == 1) {
72            if (running) {
73                // Single timer is running.
74                if (TextUtils.isEmpty(timer.getLabel())) {
75                    stateText = res.getString(R.string.timer_notification_label);
76                } else {
77                    stateText = timer.getLabel();
78                }
79
80                // Left button: Pause
81                final Intent pause = new Intent(context, TimerService.class)
82                        .setAction(TimerService.ACTION_PAUSE_TIMER)
83                        .putExtra(TimerService.EXTRA_TIMER_ID, timer.getId());
84
85                @DrawableRes final int icon1 = R.drawable.ic_pause_24dp;
86                final CharSequence title1 = res.getText(R.string.timer_pause);
87                final PendingIntent intent1 = Utils.pendingServiceIntent(context, pause);
88                actions.add(new Action.Builder(icon1, title1, intent1).build());
89
90                // Right Button: +1 Minute
91                final Intent addMinute = new Intent(context, TimerService.class)
92                        .setAction(TimerService.ACTION_ADD_MINUTE_TIMER)
93                        .putExtra(TimerService.EXTRA_TIMER_ID, timer.getId());
94
95                @DrawableRes final int icon2 = R.drawable.ic_add_24dp;
96                final CharSequence title2 = res.getText(R.string.timer_plus_1_min);
97                final PendingIntent intent2 = Utils.pendingServiceIntent(context, addMinute);
98                actions.add(new Action.Builder(icon2, title2, intent2).build());
99
100            } else {
101                // Single timer is paused.
102                stateText = res.getString(R.string.timer_paused);
103
104                // Left button: Start
105                final Intent start = new Intent(context, TimerService.class)
106                        .setAction(TimerService.ACTION_START_TIMER)
107                        .putExtra(TimerService.EXTRA_TIMER_ID, timer.getId());
108
109                @DrawableRes final int icon1 = R.drawable.ic_start_24dp;
110                final CharSequence title1 = res.getText(R.string.sw_resume_button);
111                final PendingIntent intent1 = Utils.pendingServiceIntent(context, start);
112                actions.add(new Action.Builder(icon1, title1, intent1).build());
113
114                // Right Button: Reset
115                final Intent reset = new Intent(context, TimerService.class)
116                        .setAction(TimerService.ACTION_RESET_TIMER)
117                        .putExtra(TimerService.EXTRA_TIMER_ID, timer.getId());
118
119                @DrawableRes final int icon2 = R.drawable.ic_reset_24dp;
120                final CharSequence title2 = res.getText(R.string.sw_reset_button);
121                final PendingIntent intent2 = Utils.pendingServiceIntent(context, reset);
122                actions.add(new Action.Builder(icon2, title2, intent2).build());
123            }
124        } else {
125            if (running) {
126                // At least one timer is running.
127                stateText = res.getString(R.string.timers_in_use, count);
128            } else {
129                // All timers are paused.
130                stateText = res.getString(R.string.timers_stopped, count);
131            }
132
133            final Intent reset = TimerService.createResetUnexpiredTimersIntent(context);
134
135            @DrawableRes final int icon1 = R.drawable.ic_reset_24dp;
136            final CharSequence title1 = res.getText(R.string.timer_reset_all);
137            final PendingIntent intent1 = Utils.pendingServiceIntent(context, reset);
138            actions.add(new Action.Builder(icon1, title1, intent1).build());
139        }
140
141        // Intent to load the app and show the timer when the notification is tapped.
142        final Intent showApp = new Intent(context, TimerService.class)
143                .setAction(TimerService.ACTION_SHOW_TIMER)
144                .putExtra(TimerService.EXTRA_TIMER_ID, timer.getId())
145                .putExtra(Events.EXTRA_EVENT_LABEL, R.string.label_notification);
146
147        final PendingIntent pendingShowApp =
148                PendingIntent.getService(context, REQUEST_CODE_UPCOMING, showApp,
149                        PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
150
151        final Builder notification = new NotificationCompat.Builder(context)
152                .setOngoing(true)
153                .setLocalOnly(true)
154                .setShowWhen(false)
155                .setAutoCancel(false)
156                .setContentIntent(pendingShowApp)
157                .setPriority(Notification.PRIORITY_HIGH)
158                .setCategory(NotificationCompat.CATEGORY_ALARM)
159                .setSmallIcon(R.drawable.stat_notify_timer)
160                .setSortKey(nm.getTimerNotificationSortKey())
161                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
162                .setStyle(new NotificationCompat.DecoratedCustomViewStyle())
163                .setColor(ContextCompat.getColor(context, R.color.default_background));
164
165        for (Action action : actions) {
166            notification.addAction(action);
167        }
168
169        if (Utils.isNOrLater()) {
170            notification.setCustomContentView(buildChronometer(pname, base, running, stateText))
171                    .setGroup(nm.getTimerNotificationGroupKey());
172        } else {
173            final CharSequence contentTextPreN;
174            if (count == 1) {
175                contentTextPreN = TimerStringFormatter.formatTimeRemaining(context,
176                        timer.getRemainingTime(), false);
177            } else if (running) {
178                final String timeRemaining = TimerStringFormatter.formatTimeRemaining(context,
179                        timer.getRemainingTime(), false);
180                contentTextPreN = context.getString(R.string.next_timer_notif, timeRemaining);
181            } else {
182                contentTextPreN = context.getString(R.string.all_timers_stopped_notif);
183            }
184
185            notification.setContentTitle(stateText).setContentText(contentTextPreN);
186
187            final AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
188            final Intent updateNotification = TimerService.createUpdateNotificationIntent(context);
189            final long remainingTime = timer.getRemainingTime();
190            if (timer.isRunning() && remainingTime > MINUTE_IN_MILLIS) {
191                // Schedule a callback to update the time-sensitive information of the running timer
192                final PendingIntent pi =
193                        PendingIntent.getService(context, REQUEST_CODE_UPCOMING, updateNotification,
194                                PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
195
196                final long nextMinuteChange = remainingTime % MINUTE_IN_MILLIS;
197                final long triggerTime = SystemClock.elapsedRealtime() + nextMinuteChange;
198                TimerModel.schedulePendingIntent(am, triggerTime, pi);
199            } else {
200                // Cancel the update notification callback.
201                final PendingIntent pi = PendingIntent.getService(context, 0, updateNotification,
202                        PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_NO_CREATE);
203                if (pi != null) {
204                    am.cancel(pi);
205                    pi.cancel();
206                }
207            }
208        }
209
210        return notification.build();
211    }
212
213    Notification buildHeadsUp(Context context, List<Timer> expired) {
214        final Timer timer = expired.get(0);
215
216        // First action intent is to reset all timers.
217        @DrawableRes final int icon1 = R.drawable.ic_stop_24dp;
218        final Intent reset = TimerService.createResetExpiredTimersIntent(context);
219        final PendingIntent intent1 = Utils.pendingServiceIntent(context, reset);
220
221        // Generate some descriptive text, a title, and an action name based on the timer count.
222        final CharSequence stateText;
223        final int count = expired.size();
224        final List<Action> actions = new ArrayList<>(2);
225        if (count == 1) {
226            final String label = timer.getLabel();
227            if (TextUtils.isEmpty(label)) {
228                stateText = context.getString(R.string.timer_times_up);
229            } else {
230                stateText = label;
231            }
232
233            // Left button: Reset single timer
234            final CharSequence title1 = context.getString(R.string.timer_stop);
235            actions.add(new Action.Builder(icon1, title1, intent1).build());
236
237            // Right button: Add minute
238            final Intent addTime = TimerService.createAddMinuteTimerIntent(context, timer.getId());
239            final PendingIntent intent2 = Utils.pendingServiceIntent(context, addTime);
240            @DrawableRes final int icon2 = R.drawable.ic_add_24dp;
241            final CharSequence title2 = context.getString(R.string.timer_plus_1_min);
242            actions.add(new Action.Builder(icon2, title2, intent2).build());
243        } else {
244            stateText = context.getString(R.string.timer_multi_times_up, count);
245
246            // Left button: Reset all timers
247            final CharSequence title1 = context.getString(R.string.timer_stop_all);
248            actions.add(new Action.Builder(icon1, title1, intent1).build());
249        }
250
251        final long base = getChronometerBase(timer);
252
253        final String pname = context.getPackageName();
254
255        // Content intent shows the timer full screen when clicked.
256        final Intent content = new Intent(context, ExpiredTimersActivity.class);
257        final PendingIntent contentIntent = Utils.pendingActivityIntent(context, content);
258
259        // Full screen intent has flags so it is different than the content intent.
260        final Intent fullScreen = new Intent(context, ExpiredTimersActivity.class)
261                .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_USER_ACTION);
262        final PendingIntent pendingFullScreen = Utils.pendingActivityIntent(context, fullScreen);
263
264        final Builder notification = new NotificationCompat.Builder(context)
265                .setOngoing(true)
266                .setLocalOnly(true)
267                .setShowWhen(false)
268                .setAutoCancel(false)
269                .setContentIntent(contentIntent)
270                .setPriority(Notification.PRIORITY_MAX)
271                .setDefaults(Notification.DEFAULT_LIGHTS)
272                .setSmallIcon(R.drawable.stat_notify_timer)
273                .setFullScreenIntent(pendingFullScreen, true)
274                .setStyle(new NotificationCompat.DecoratedCustomViewStyle())
275                .setColor(ContextCompat.getColor(context, R.color.default_background));
276
277        for (Action action : actions) {
278            notification.addAction(action);
279        }
280
281        if (Utils.isNOrLater()) {
282            notification.setCustomContentView(buildChronometer(pname, base, true, stateText));
283        } else {
284            final CharSequence contentTextPreN = count == 1
285                    ? context.getString(R.string.timer_times_up)
286                    : context.getString(R.string.timer_multi_times_up, count);
287
288            notification.setContentTitle(stateText).setContentText(contentTextPreN);
289        }
290
291        return notification.build();
292    }
293
294    Notification buildMissed(Context context, NotificationModel nm,
295            List<Timer> missedTimers) {
296        final Timer timer = missedTimers.get(0);
297        final int count = missedTimers.size();
298
299        // Compute some values required below.
300        final long base = getChronometerBase(timer);
301        final String pname = context.getPackageName();
302        final Resources res = context.getResources();
303
304        final Action action;
305
306        final CharSequence stateText;
307        if (count == 1) {
308            // Single timer is missed.
309            if (TextUtils.isEmpty(timer.getLabel())) {
310                stateText = res.getString(R.string.missed_timer_notification_label);
311            } else {
312                stateText = res.getString(R.string.missed_named_timer_notification_label,
313                        timer.getLabel());
314            }
315
316            // Reset button
317            final Intent reset = new Intent(context, TimerService.class)
318                    .setAction(TimerService.ACTION_RESET_TIMER)
319                    .putExtra(TimerService.EXTRA_TIMER_ID, timer.getId());
320
321            @DrawableRes final int icon1 = R.drawable.ic_reset_24dp;
322            final CharSequence title1 = res.getText(R.string.timer_reset);
323            final PendingIntent intent1 = Utils.pendingServiceIntent(context, reset);
324            action = new Action.Builder(icon1, title1, intent1).build();
325        } else {
326            // Multiple missed timers.
327            stateText = res.getString(R.string.timer_multi_missed, count);
328
329            final Intent reset = TimerService.createResetMissedTimersIntent(context);
330
331            @DrawableRes final int icon1 = R.drawable.ic_reset_24dp;
332            final CharSequence title1 = res.getText(R.string.timer_reset_all);
333            final PendingIntent intent1 = Utils.pendingServiceIntent(context, reset);
334            action = new Action.Builder(icon1, title1, intent1).build();
335        }
336
337        // Intent to load the app and show the timer when the notification is tapped.
338        final Intent showApp = new Intent(context, TimerService.class)
339                .setAction(TimerService.ACTION_SHOW_TIMER)
340                .putExtra(TimerService.EXTRA_TIMER_ID, timer.getId())
341                .putExtra(Events.EXTRA_EVENT_LABEL, R.string.label_notification);
342
343        final PendingIntent pendingShowApp =
344                PendingIntent.getService(context, REQUEST_CODE_MISSING, showApp,
345                        PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
346
347        final Builder notification = new NotificationCompat.Builder(context)
348                .setLocalOnly(true)
349                .setShowWhen(false)
350                .setAutoCancel(false)
351                .setContentIntent(pendingShowApp)
352                .setPriority(Notification.PRIORITY_HIGH)
353                .setCategory(NotificationCompat.CATEGORY_ALARM)
354                .setSmallIcon(R.drawable.stat_notify_timer)
355                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
356                .setSortKey(nm.getTimerNotificationMissedSortKey())
357                .setStyle(new NotificationCompat.DecoratedCustomViewStyle())
358                .addAction(action)
359                .setColor(ContextCompat.getColor(context, R.color.default_background));
360
361        if (Utils.isNOrLater()) {
362            notification.setCustomContentView(buildChronometer(pname, base, true, stateText))
363                    .setGroup(nm.getTimerNotificationGroupKey());
364        } else {
365            final CharSequence contentText = AlarmUtils.getFormattedTime(context,
366                    timer.getWallClockExpirationTime());
367            notification.setContentText(contentText).setContentTitle(stateText);
368        }
369
370        return notification.build();
371    }
372
373    /**
374     * @param timer the timer on which to base the chronometer display
375     * @return the time at which the chronometer will/did reach 0:00 in realtime
376     */
377    private static long getChronometerBase(Timer timer) {
378        // The in-app timer display rounds *up* to the next second for positive timer values. Mirror
379        // that behavior in the notification's Chronometer by padding in an extra second as needed.
380        final long remaining = timer.getRemainingTime();
381        final long adjustedRemaining = remaining < 0 ? remaining : remaining + SECOND_IN_MILLIS;
382
383        // Chronometer will/did reach 0:00 adjustedRemaining milliseconds from now.
384        return SystemClock.elapsedRealtime() + adjustedRemaining;
385    }
386
387    @TargetApi(Build.VERSION_CODES.N)
388    private RemoteViews buildChronometer(String pname, long base, boolean running,
389            CharSequence stateText) {
390        final RemoteViews content = new RemoteViews(pname, R.layout.chronometer_notif_content);
391        content.setChronometerCountDown(R.id.chronometer, true);
392        content.setChronometer(R.id.chronometer, base, null, running);
393        content.setTextViewText(R.id.state, stateText);
394        return content;
395    }
396}
397