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