blob: adcc2153a391ee29177d87afa4bf1f3d41fe8615 [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.tv.btservices;
import static android.app.NotificationManager.IMPORTANCE_DEFAULT;
import static android.app.NotificationManager.IMPORTANCE_HIGH;
import static android.app.NotificationManager.IMPORTANCE_LOW;
import static android.app.NotificationManager.IMPORTANCE_MAX;
import android.app.Notification;
import android.app.Notification.TvExtender;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Icon;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.google.android.tv.btservices.settings.RemoteDfuConfirmationActivity;
import com.google.common.base.Stopwatch;
import com.google.common.base.Ticker;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Singleton that manages all notifications posted by BluetoothDeviceService.
*
* <p>This singleton class provides static methods for other components within
* this service to post Android system notifications. This currently includes
* low battery notification, device connect/disconnect notification, etc.
*/
public class NotificationCenter {
private static final String TAG = "Atv.BtServices.NotificationCenter";
private static final long REMOTE_UPDATE_SNOOZE_PERIOD =
TimeUnit.MILLISECONDS.convert(12, TimeUnit.HOURS);
private static final String DFU_NOTIFICATION_CHANNEL = "btservices-remote-dfu-channel";
private static final String DEFAULT_NOTIFICATION_CHANNEL = "btservices-default-channel";
private static final String HIGH_PRIORITY_NOTIFICATION_CHANNEL = "btservices-high-channel";
private static final String CRITICAL_NOTIFICATION_CHANNEL = "btservices-critical-channel";
private static final int NOTIFICATION_RESET_HOUR_OF_DAY = 3;
private static class InstanceHolder {
public static NotificationCenter instance = new NotificationCenter();
}
private static NotificationCenter getInstance() {
return InstanceHolder.instance;
}
/**
* Represents different battery state for battery notification.
*
* <p>{@code GOOD} represents good battery level that does not require
* notification; {@code LOW} represents low battery level, user will be
* notified to change battery soon; {@code CRITICAL} represents that battery
* is so low that the device has disconnected and is no longer functional.
*/
public enum BatteryState {
GOOD,
LOW,
CRITICAL,
DEPLETED,
}
public static synchronized void initialize(Context context) {
NotificationCenter nc = getInstance();
nc.mContext = context;
nc.mNotificationManager = context.getSystemService(NotificationManager.class);
nc.createNotificationChannel();
}
public static synchronized void refreshLowBatteryNotification(
BluetoothDevice device,
BatteryState state,
boolean forceNotification) {
getInstance().refreshLowBatteryNotificationImpl(device, state, forceNotification);
}
public static synchronized void sendDfuNotification(BluetoothDevice device) {
getInstance().sendDfuNotificationImpl(device);
}
public static synchronized void dismissUpdateNotification(BluetoothDevice device) {
getInstance().dismissUpdateNotificationImpl(device);
}
public static synchronized void resetUpdateNotification() {
getInstance().dfuNotificationSnoozeWatch.clear();
}
private void createNotificationChannel() {
// Create notification channel for firmware update notification
CharSequence dfuName = mContext.getString(R.string.settings_notif_update_channel_name);
String dfuDescr = mContext.getString(R.string.settings_notif_update_channel_description);
ensureNotificationChannel(DFU_NOTIFICATION_CHANNEL, IMPORTANCE_MAX, dfuName, dfuDescr);
// Create notification channels with different priorities for battery notifications
CharSequence name = mContext.getString(R.string.settings_notif_battery_channel_name);
String descr = mContext.getString(R.string.settings_notif_battery_channel_description);
ensureNotificationChannel(DEFAULT_NOTIFICATION_CHANNEL, IMPORTANCE_LOW, name, descr);
ensureNotificationChannel(HIGH_PRIORITY_NOTIFICATION_CHANNEL, IMPORTANCE_DEFAULT, name,
descr);
ensureNotificationChannel(CRITICAL_NOTIFICATION_CHANNEL, IMPORTANCE_HIGH, name, descr);
}
private void ensureNotificationChannel(String channelId,
int importance, CharSequence name, String description) {
if (mNotificationManager.getNotificationChannel(channelId) != null) {
return;
}
NotificationChannel channel = new NotificationChannel(channelId, name, importance);
channel.setDescription(description);
mNotificationManager.createNotificationChannel(channel);
}
private final Map<BluetoothDevice, Integer> dfuNotifications = new HashMap<>();
private final Map<BluetoothDevice, Integer> lowBatteryNotifications = new HashMap<>();
private final Map<BluetoothDevice, Integer> criticalBatteryNotifications = new HashMap<>();
private final Map<BluetoothDevice, Integer> depletedBatteryNotifications = new HashMap<>();
private final Map<BluetoothDevice, Stopwatch> dfuNotificationSnoozeWatch = new HashMap<>();
private Context mContext;
private NotificationManager mNotificationManager;
private int notificationIdCounter = 0;
private ZonedDateTime lastNotificationTime =
Instant.ofEpochSecond(0).atZone(ZoneId.systemDefault());
private final Ticker ticker = new Ticker() {
public long read() {
return android.os.SystemClock.elapsedRealtimeNanos();
}
};
private NotificationCenter() {}
private void sendDfuNotificationImpl(BluetoothDevice device) {
if (device == null) {
Log.w(TAG, "sendDfuNotification: Bluetooth device null");
return;
}
Stopwatch stopwatch = dfuNotificationSnoozeWatch.get(device);
if (stopwatch != null &&
stopwatch.elapsed(TimeUnit.MILLISECONDS) < REMOTE_UPDATE_SNOOZE_PERIOD) {
return;
}
if (stopwatch == null) {
stopwatch = Stopwatch.createStarted(ticker);
dfuNotificationSnoozeWatch.put(device, stopwatch);
}
stopwatch.reset();
stopwatch.start();
int notificationId;
if (dfuNotifications.get(device) != null) {
notificationId = dfuNotifications.get(device);
} else {
notificationId = notificationIdCounter++;
dfuNotifications.put(device, notificationId);
}
final String name = BluetoothUtils.getName(device);
Intent intent = new Intent(mContext, RemoteDfuConfirmationActivity.class);
intent.putExtra(RemoteDfuConfirmationActivity.EXTRA_BT_ADDRESS, device.getAddress());
intent.putExtra(RemoteDfuConfirmationActivity.EXTRA_BT_NAME, name);
PendingIntent updateIntent = PendingIntent.getActivity(mContext,
/* requestCode= */ 0, intent,
PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
Notification.Action updateAction = new Notification.Action.Builder(/* icon= */ null,
mContext.getString(R.string.settings_notif_update_action),
updateIntent).build();
Notification.Action dismissAction = new Notification.Action.Builder(/* icon= */ null,
mContext.getString(R.string.settings_notif_update_dismiss), null)
.setSemanticAction(Notification.Action.SEMANTIC_ACTION_DELETE)
.build();
Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote);
Notification notification = new Notification.Builder(mContext, DFU_NOTIFICATION_CHANNEL)
.setSmallIcon(icon)
.setContentTitle(mContext.getString(R.string.settings_notif_update_title))
.setContentText(mContext.getString(R.string.settings_notif_update_text))
.setContentIntent(updateIntent)
.setPriority(NotificationCompat.PRIORITY_MAX)
.addAction(updateAction)
.addAction(dismissAction)
.extend(new TvExtender())
.build();
mNotificationManager.notify(notificationId, notification);
}
private void dismissUpdateNotificationImpl(BluetoothDevice device) {
if (dfuNotifications.get(device) != null) {
int notificationId = dfuNotifications.get(device);
mNotificationManager.cancel(notificationId);
}
}
private void refreshLowBatteryNotificationImpl(BluetoothDevice device, BatteryState state,
boolean forceNotification) {
// Dismiss outdated notifications.
if (state != BatteryState.LOW) {
if (lowBatteryNotifications.get(device) != null) {
int notificationId = lowBatteryNotifications.remove(device);
mNotificationManager.cancel(notificationId);
}
}
if (state != BatteryState.CRITICAL) {
if (criticalBatteryNotifications.get(device) != null) {
int notificationId = criticalBatteryNotifications.remove(device);
mNotificationManager.cancel(notificationId);
}
}
switch (state) {
case GOOD:
// do nothing
break;
case LOW:
postLowBatteryNotification(device, forceNotification);
break;
case CRITICAL:
postCriticalBatteryNotification(device, forceNotification);
break;
case DEPLETED:
postDepletedBatteryNotification(device);
break;
default:
// impossible to reach
throw new AssertionError();
}
}
private void postLowBatteryNotification(BluetoothDevice device, boolean forced) {
if ((!forced && lowBatteryNotifications.get(device) != null) || !isNotificationAllowed()) {
return;
}
int notificationId = lowBatteryNotifications.getOrDefault(device, notificationIdCounter);
if (notificationId == notificationIdCounter) {
notificationIdCounter++;
lowBatteryNotifications.put(device, notificationId);
}
Log.w(TAG, "Low battery for remote device: " + device);
Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote);
Notification notification = new Notification.Builder(mContext, DEFAULT_NOTIFICATION_CHANNEL)
.setSmallIcon(icon)
.setContentTitle(mContext.getString(R.string.settings_notif_low_battery_title))
.setContentText(mContext.getString(R.string.settings_notif_low_battery_text))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.extend(new TvExtender())
.build();
mNotificationManager.notify(notificationId, notification);
logLastNotificationTime();
}
private void postCriticalBatteryNotification(BluetoothDevice device, boolean forced) {
if ((!forced && criticalBatteryNotifications.get(device) != null) ||
!isNotificationAllowed()) {
return;
}
int notificationId = criticalBatteryNotifications.getOrDefault(device, notificationIdCounter);
if (notificationId == notificationIdCounter) {
notificationIdCounter++;
criticalBatteryNotifications.put(device, notificationId);
}
Log.w(TAG, "Critical battery for remote device: " + device);
Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote);
Notification notification =
new Notification.Builder(mContext, HIGH_PRIORITY_NOTIFICATION_CHANNEL)
.setSmallIcon(icon)
.setContentTitle(mContext.getString(R.string.settings_notif_critical_battery_title))
.setContentText(mContext.getString(R.string.settings_notif_critical_battery_text))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.extend(new TvExtender())
.build();
mNotificationManager.notify(notificationId, notification);
logLastNotificationTime();
}
private void postDepletedBatteryNotification(BluetoothDevice device) {
if (depletedBatteryNotifications.get(device) != null || !isNotificationAllowed()) {
return;
}
int notificationId = notificationIdCounter++;
depletedBatteryNotifications.put(device, notificationId);
Log.w(TAG, "Depleted battery for remote device: " + device);
Icon icon = Icon.createWithResource(mContext, R.drawable.ic_official_remote);
Notification notification =
new Notification.Builder(mContext, HIGH_PRIORITY_NOTIFICATION_CHANNEL)
.setSmallIcon(icon)
.setContentTitle(mContext.getString(R.string.settings_notif_depleted_battery_title))
.setContentText(mContext.getString(R.string.settings_notif_depleted_battery_text))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.extend(new TvExtender())
.build();
mNotificationManager.notify(notificationId, notification);
logLastNotificationTime();
}
private void logLastNotificationTime() {
lastNotificationTime = Instant.now().atZone(ZoneId.systemDefault());
}
private boolean isNotificationAllowed() {
final ZonedDateTime currentTime = Instant.now().atZone(ZoneId.systemDefault());
final ZonedDateTime resetTime =
lastNotificationTime.plusDays(1).withHour(NOTIFICATION_RESET_HOUR_OF_DAY).withMinute(0);
// return true if it has passed notification reset time
return resetTime.until(currentTime, ChronoUnit.MILLIS) > 0;
}
}