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 }