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