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