1 /* 2 * Copyright (C) 2021 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.google.android.tv.btservices; 18 19 import static android.app.NotificationManager.IMPORTANCE_DEFAULT; 20 import static android.app.NotificationManager.IMPORTANCE_HIGH; 21 import static android.app.NotificationManager.IMPORTANCE_LOW; 22 import static android.app.NotificationManager.IMPORTANCE_MAX; 23 24 import android.app.Notification; 25 import android.app.Notification.TvExtender; 26 import android.app.NotificationChannel; 27 import android.app.NotificationManager; 28 import android.app.PendingIntent; 29 import android.bluetooth.BluetoothDevice; 30 import android.content.Context; 31 import android.content.Intent; 32 import android.graphics.drawable.Icon; 33 import android.util.Log; 34 import androidx.core.app.NotificationCompat; 35 import com.google.android.tv.btservices.settings.RemoteDfuConfirmationActivity; 36 import com.google.common.base.Stopwatch; 37 import com.google.common.base.Ticker; 38 39 import java.time.Instant; 40 import java.time.ZonedDateTime; 41 import java.time.ZoneId; 42 import java.time.temporal.ChronoUnit; 43 import java.util.HashMap; 44 import java.util.Map; 45 import java.util.concurrent.TimeUnit; 46 47 /** 48 * Singleton that manages all notifications posted by BluetoothDeviceService. 49 * 50 * <p>This singleton class provides static methods for other components within 51 * this service to post Android system notifications. This currently includes 52 * low battery notification, device connect/disconnect notification, etc. 53 */ 54 public class NotificationCenter { 55 private static final String TAG = "Atv.BtServices.NotificationCenter"; 56 57 private static final long REMOTE_UPDATE_SNOOZE_PERIOD = 58 TimeUnit.MILLISECONDS.convert(12, TimeUnit.HOURS); 59 private static final String DFU_NOTIFICATION_CHANNEL = "btservices-remote-dfu-channel"; 60 private static final String DEFAULT_NOTIFICATION_CHANNEL = "btservices-default-channel"; 61 private static final String HIGH_PRIORITY_NOTIFICATION_CHANNEL = "btservices-high-channel"; 62 private static final String CRITICAL_NOTIFICATION_CHANNEL = "btservices-critical-channel"; 63 64 private static final int NOTIFICATION_RESET_HOUR_OF_DAY = 3; 65 66 private static class InstanceHolder { 67 public static NotificationCenter instance = new NotificationCenter(); 68 } 69 getInstance()70 private static NotificationCenter getInstance() { 71 return InstanceHolder.instance; 72 } 73 74 /** 75 * Represents different battery state for battery notification. 76 * 77 * <p>{@code GOOD} represents good battery level that does not require 78 * notification; {@code LOW} represents low battery level, user will be 79 * notified to change battery soon; {@code CRITICAL} represents that battery 80 * is so low that the device has disconnected and is no longer functional. 81 */ 82 public enum BatteryState { 83 GOOD, 84 LOW, 85 CRITICAL, 86 DEPLETED, 87 } 88 initialize(Context context)89 public static synchronized void initialize(Context context) { 90 NotificationCenter nc = getInstance(); 91 nc.mContext = context; 92 nc.mNotificationManager = context.getSystemService(NotificationManager.class); 93 nc.createNotificationChannel(); 94 } 95 refreshLowBatteryNotification( BluetoothDevice device, BatteryState state, boolean forceNotification)96 public static synchronized void refreshLowBatteryNotification( 97 BluetoothDevice device, 98 BatteryState state, 99 boolean forceNotification) { 100 getInstance().refreshLowBatteryNotificationImpl(device, state, forceNotification); 101 } 102 sendDfuNotification(BluetoothDevice device)103 public static synchronized void sendDfuNotification(BluetoothDevice device) { 104 getInstance().sendDfuNotificationImpl(device); 105 } 106 dismissUpdateNotification(BluetoothDevice device)107 public static synchronized void dismissUpdateNotification(BluetoothDevice device) { 108 getInstance().dismissUpdateNotificationImpl(device); 109 } 110 resetUpdateNotification()111 public static synchronized void resetUpdateNotification() { 112 getInstance().dfuNotificationSnoozeWatch.clear(); 113 } 114 createNotificationChannel()115 private void createNotificationChannel() { 116 // Create notification channel for firmware update notification 117 CharSequence dfuName = mContext.getString(R.string.settings_notif_update_channel_name); 118 String dfuDescr = mContext.getString(R.string.settings_notif_update_channel_description); 119 ensureNotificationChannel(DFU_NOTIFICATION_CHANNEL, IMPORTANCE_MAX, dfuName, dfuDescr); 120 121 // Create notification channels with different priorities for battery notifications 122 CharSequence name = mContext.getString(R.string.settings_notif_battery_channel_name); 123 String descr = mContext.getString(R.string.settings_notif_battery_channel_description); 124 ensureNotificationChannel(DEFAULT_NOTIFICATION_CHANNEL, IMPORTANCE_LOW, name, descr); 125 ensureNotificationChannel(HIGH_PRIORITY_NOTIFICATION_CHANNEL, IMPORTANCE_DEFAULT, name, 126 descr); 127 ensureNotificationChannel(CRITICAL_NOTIFICATION_CHANNEL, IMPORTANCE_HIGH, name, descr); 128 } 129 ensureNotificationChannel(String channelId, int importance, CharSequence name, String description)130 private void ensureNotificationChannel(String channelId, 131 int importance, CharSequence name, String description) { 132 if (mNotificationManager.getNotificationChannel(channelId) != null) { 133 return; 134 } 135 NotificationChannel channel = new NotificationChannel(channelId, name, importance); 136 channel.setDescription(description); 137 mNotificationManager.createNotificationChannel(channel); 138 } 139 140 141 private final Map<BluetoothDevice, Integer> dfuNotifications = new HashMap<>(); 142 private final Map<BluetoothDevice, Integer> lowBatteryNotifications = new HashMap<>(); 143 private final Map<BluetoothDevice, Integer> criticalBatteryNotifications = new HashMap<>(); 144 private final Map<BluetoothDevice, Integer> depletedBatteryNotifications = new HashMap<>(); 145 private final Map<BluetoothDevice, Stopwatch> dfuNotificationSnoozeWatch = new HashMap<>(); 146 private Context mContext; 147 private NotificationManager mNotificationManager; 148 private int notificationIdCounter = 0; 149 private ZonedDateTime lastNotificationTime = 150 Instant.ofEpochSecond(0).atZone(ZoneId.systemDefault()); 151 152 private final Ticker ticker = new Ticker() { 153 public long read() { 154 return android.os.SystemClock.elapsedRealtimeNanos(); 155 } 156 }; 157 NotificationCenter()158 private NotificationCenter() {} 159 sendDfuNotificationImpl(BluetoothDevice device)160 private void sendDfuNotificationImpl(BluetoothDevice device) { 161 if (device == null) { 162 Log.w(TAG, "sendDfuNotification: Bluetooth device null"); 163 return; 164 } 165 166 Stopwatch stopwatch = dfuNotificationSnoozeWatch.get(device); 167 if (stopwatch != null && 168 stopwatch.elapsed(TimeUnit.MILLISECONDS) < REMOTE_UPDATE_SNOOZE_PERIOD) { 169 return; 170 } 171 172 if (stopwatch == null) { 173 stopwatch = Stopwatch.createStarted(ticker); 174 dfuNotificationSnoozeWatch.put(device, stopwatch); 175 } 176 177 stopwatch.reset(); 178 stopwatch.start(); 179 180 int notificationId; 181 if (dfuNotifications.get(device) != null) { 182 notificationId = dfuNotifications.get(device); 183 } else { 184 notificationId = notificationIdCounter++; 185 dfuNotifications.put(device, notificationId); 186 } 187 final String name = BluetoothUtils.getName(device); 188 Intent intent = new Intent(mContext, RemoteDfuConfirmationActivity.class); 189 intent.putExtra(RemoteDfuConfirmationActivity.EXTRA_BT_ADDRESS, device.getAddress()); 190 intent.putExtra(RemoteDfuConfirmationActivity.EXTRA_BT_NAME, name); 191 192 PendingIntent updateIntent = PendingIntent.getActivity(mContext, 193 /* requestCode= */ 0, intent, 194 PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 195 196 Notification.Action updateAction = new Notification.Action.Builder(/* icon= */ null, 197 mContext.getString(R.string.settings_notif_update_action), 198 updateIntent).build(); 199 200 Notification.Action dismissAction = new Notification.Action.Builder(/* icon= */ null, 201 mContext.getString(R.string.settings_notif_update_dismiss), null) 202 .setSemanticAction(Notification.Action.SEMANTIC_ACTION_DELETE) 203 .build(); 204 205 Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote); 206 Notification notification = new Notification.Builder(mContext, DFU_NOTIFICATION_CHANNEL) 207 .setSmallIcon(icon) 208 .setContentTitle(mContext.getString(R.string.settings_notif_update_title)) 209 .setContentText(mContext.getString(R.string.settings_notif_update_text)) 210 .setContentIntent(updateIntent) 211 .setPriority(NotificationCompat.PRIORITY_MAX) 212 .addAction(updateAction) 213 .addAction(dismissAction) 214 .extend(new TvExtender()) 215 .build(); 216 mNotificationManager.notify(notificationId, notification); 217 } 218 dismissUpdateNotificationImpl(BluetoothDevice device)219 private void dismissUpdateNotificationImpl(BluetoothDevice device) { 220 if (dfuNotifications.get(device) != null) { 221 int notificationId = dfuNotifications.get(device); 222 mNotificationManager.cancel(notificationId); 223 } 224 } 225 refreshLowBatteryNotificationImpl(BluetoothDevice device, BatteryState state, boolean forceNotification)226 private void refreshLowBatteryNotificationImpl(BluetoothDevice device, BatteryState state, 227 boolean forceNotification) { 228 // Dismiss outdated notifications. 229 if (state != BatteryState.LOW) { 230 if (lowBatteryNotifications.get(device) != null) { 231 int notificationId = lowBatteryNotifications.remove(device); 232 mNotificationManager.cancel(notificationId); 233 } 234 } 235 236 if (state != BatteryState.CRITICAL) { 237 if (criticalBatteryNotifications.get(device) != null) { 238 int notificationId = criticalBatteryNotifications.remove(device); 239 mNotificationManager.cancel(notificationId); 240 } 241 } 242 243 switch (state) { 244 case GOOD: 245 // do nothing 246 break; 247 248 case LOW: 249 postLowBatteryNotification(device, forceNotification); 250 break; 251 252 case CRITICAL: 253 postCriticalBatteryNotification(device, forceNotification); 254 break; 255 256 case DEPLETED: 257 postDepletedBatteryNotification(device); 258 break; 259 260 default: 261 // impossible to reach 262 throw new AssertionError(); 263 } 264 } 265 postLowBatteryNotification(BluetoothDevice device, boolean forced)266 private void postLowBatteryNotification(BluetoothDevice device, boolean forced) { 267 if ((!forced && lowBatteryNotifications.get(device) != null) || !isNotificationAllowed()) { 268 return; 269 } 270 271 int notificationId = lowBatteryNotifications.getOrDefault(device, notificationIdCounter); 272 273 if (notificationId == notificationIdCounter) { 274 notificationIdCounter++; 275 lowBatteryNotifications.put(device, notificationId); 276 } 277 278 Log.w(TAG, "Low battery for remote device: " + device); 279 Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote); 280 281 Notification notification = new Notification.Builder(mContext, DEFAULT_NOTIFICATION_CHANNEL) 282 .setSmallIcon(icon) 283 .setContentTitle(mContext.getString(R.string.settings_notif_low_battery_title)) 284 .setContentText(mContext.getString(R.string.settings_notif_low_battery_text)) 285 .setPriority(NotificationCompat.PRIORITY_DEFAULT) 286 .extend(new TvExtender()) 287 .build(); 288 mNotificationManager.notify(notificationId, notification); 289 logLastNotificationTime(); 290 } 291 postCriticalBatteryNotification(BluetoothDevice device, boolean forced)292 private void postCriticalBatteryNotification(BluetoothDevice device, boolean forced) { 293 294 if ((!forced && criticalBatteryNotifications.get(device) != null) || 295 !isNotificationAllowed()) { 296 return; 297 } 298 299 int notificationId = criticalBatteryNotifications.getOrDefault(device, notificationIdCounter); 300 301 if (notificationId == notificationIdCounter) { 302 notificationIdCounter++; 303 criticalBatteryNotifications.put(device, notificationId); 304 } 305 306 Log.w(TAG, "Critical battery for remote device: " + device); 307 Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote); 308 309 Notification notification = 310 new Notification.Builder(mContext, HIGH_PRIORITY_NOTIFICATION_CHANNEL) 311 .setSmallIcon(icon) 312 .setContentTitle(mContext.getString(R.string.settings_notif_critical_battery_title)) 313 .setContentText(mContext.getString(R.string.settings_notif_critical_battery_text)) 314 .setPriority(NotificationCompat.PRIORITY_DEFAULT) 315 .extend(new TvExtender()) 316 .build(); 317 mNotificationManager.notify(notificationId, notification); 318 logLastNotificationTime(); 319 } 320 postDepletedBatteryNotification(BluetoothDevice device)321 private void postDepletedBatteryNotification(BluetoothDevice device) { 322 323 if (depletedBatteryNotifications.get(device) != null || !isNotificationAllowed()) { 324 return; 325 } 326 327 int notificationId = notificationIdCounter++; 328 depletedBatteryNotifications.put(device, notificationId); 329 330 Log.w(TAG, "Depleted battery for remote device: " + device); 331 Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote); 332 333 Notification notification = 334 new Notification.Builder(mContext, HIGH_PRIORITY_NOTIFICATION_CHANNEL) 335 .setSmallIcon(icon) 336 .setContentTitle(mContext.getString(R.string.settings_notif_depleted_battery_title)) 337 .setContentText(mContext.getString(R.string.settings_notif_depleted_battery_text)) 338 .setPriority(NotificationCompat.PRIORITY_DEFAULT) 339 .extend(new TvExtender()) 340 .build(); 341 mNotificationManager.notify(notificationId, notification); 342 logLastNotificationTime(); 343 } 344 logLastNotificationTime()345 private void logLastNotificationTime() { 346 lastNotificationTime = Instant.now().atZone(ZoneId.systemDefault()); 347 } 348 isNotificationAllowed()349 private boolean isNotificationAllowed() { 350 final ZonedDateTime currentTime = Instant.now().atZone(ZoneId.systemDefault()); 351 final ZonedDateTime resetTime = 352 lastNotificationTime.plusDays(1).withHour(NOTIFICATION_RESET_HOUR_OF_DAY).withMinute(0); 353 354 // return true if it has passed notification reset time 355 return resetTime.until(currentTime, ChronoUnit.MILLIS) > 0; 356 } 357 } 358