// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.bluetooth;
import android.content.Context;
import android.content.Intent;
import android.util.SparseIntArray;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import org.chromium.base.ContextUtils;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.notifications.NotificationUmaTracker;
import org.chromium.chrome.browser.notifications.NotificationWrapperBuilderFactory;
import org.chromium.chrome.browser.notifications.channels.ChromeChannelDefinitions;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.browser_ui.notifications.BaseNotificationManagerProxy;
import org.chromium.components.browser_ui.notifications.NotificationMetadata;
import org.chromium.components.browser_ui.notifications.NotificationWrapper;
import org.chromium.components.browser_ui.notifications.NotificationWrapperBuilder;
import org.chromium.components.browser_ui.notifications.PendingIntentProvider;
import org.chromium.components.url_formatter.SchemeDisplay;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.content_public.browser.ContentFeatureList;
import org.chromium.content_public.browser.ContentFeatureMap;
import org.chromium.content_public.browser.WebContents;
import org.chromium.url.GURL;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
/**
* Creates and destroys the Web Bluetooth notification when a website is either connected
* to a Bluetooth device or scanning for nearby Bluetooth devices.
*/
public class BluetoothNotificationManager {
private static final String NOTIFICATION_NAMESPACE = "BluetoothNotificationManager";
public static final String ACTION_BLUETOOTH_UPDATE =
"org.chromium.chrome.browser.app.bluetooth.BLUETOOTH_UPDATE";
public static final String NOTIFICATION_ID_EXTRA = "NotificationId";
public static final String NOTIFICATION_IS_INCOGNITO = "NotificationIsIncognito";
public static final String NOTIFICATION_BLUETOOTH_TYPE_EXTRA = "NotificationBluetoothType";
public static final String NOTIFICATION_URL_EXTRA = "NotificationUrl";
@IntDef({BluetoothType.NO_BLUETOOTH, BluetoothType.IS_CONNECTED, BluetoothType.IS_SCANNING})
public @interface BluetoothType {
int NO_BLUETOOTH = 0;
int IS_CONNECTED = 1;
int IS_SCANNING = 2;
}
private BluetoothNotificationManagerDelegate mDelegate;
private BaseNotificationManagerProxy mNotificationManager;
private SharedPreferencesManager mSharedPreferences;
private final SparseIntArray mNotifications = new SparseIntArray();
public BluetoothNotificationManager(
BaseNotificationManagerProxy notificationManager,
BluetoothNotificationManagerDelegate delegate) {
mDelegate = delegate;
mNotificationManager = notificationManager;
mSharedPreferences = ChromeSharedPreferences.getInstance();
}
/**
* @param notificationId Unique id of the notification.
* @param bluetoothType Bluetooth type of the notification.
* @return Whether the notification has already been created for provided notification id and
* bluetoothType.
*/
private boolean doesNotificationNeedUpdate(
int notificationId, @BluetoothType int bluetoothType) {
return mNotifications.get(notificationId) != bluetoothType;
}
/**
* @param notificationId Unique id of the notification.
* @return Whether the notification has already been created for the provided notification id.
*/
private boolean doesNotificationExist(int notificationId) {
return mNotifications.indexOfKey(notificationId) >= 0;
}
public void onStartCommand(Intent intent, int flags, int startId) {
if (intent == null || intent.getExtras() == null) {
cancelPreviousBluetoothNotifications();
mDelegate.stopSelf();
} else if (ACTION_BLUETOOTH_UPDATE.equals(intent.getAction())) {
int notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, Tab.INVALID_TAB_ID);
int bluetoothType =
intent.getIntExtra(
NOTIFICATION_BLUETOOTH_TYPE_EXTRA, BluetoothType.NO_BLUETOOTH);
String url = intent.getStringExtra(NOTIFICATION_URL_EXTRA);
boolean isIncognito = intent.getBooleanExtra(NOTIFICATION_IS_INCOGNITO, false);
updateNotification(notificationId, bluetoothType, url, isIncognito, startId);
}
}
/**
* Cancel all previously existing notifications. Essential while doing a clean start (may be
* after a browser crash which caused old notifications to exist).
*/
public void cancelPreviousBluetoothNotifications() {
Set<String> notificationIds =
mSharedPreferences.readStringSet(
ChromePreferenceKeys.BLUETOOTH_NOTIFICATION_IDS, null);
if (notificationIds == null) return;
Iterator<String> iterator = notificationIds.iterator();
while (iterator.hasNext()) {
mNotificationManager.cancel(NOTIFICATION_NAMESPACE, Integer.parseInt(iterator.next()));
}
mSharedPreferences.removeKey(ChromePreferenceKeys.BLUETOOTH_NOTIFICATION_IDS);
}
/**
* Updates the existing notification or creates one if none exist for the provided
* notificationId and bluetoothType.
* @param notificationId Unique id of the notification.
* @param bluetoothType Bluetooth type of the notification.
* @param url Url of the website interacting with Bluetooth devices.
* @param isIncognito Whether the notification comes from incognito mode.
* @param startId Id for the service start request
*/
private void updateNotification(
int notificationId,
@BluetoothType int bluetoothType,
String url,
boolean isIncognito,
int startId) {
if (doesNotificationExist(notificationId)
&& !doesNotificationNeedUpdate(notificationId, bluetoothType)) {
return;
}
destroyNotification(notificationId);
if (bluetoothType != BluetoothType.NO_BLUETOOTH) {
createNotification(notificationId, bluetoothType, url, isIncognito);
}
if (mNotifications.size() == 0) mDelegate.stopSelf(startId);
}
/**
* Destroys the notification for the id notificationId.
* @param notificationId Unique id of the notification.
*/
private void destroyNotification(int notificationId) {
if (doesNotificationExist(notificationId)) {
mNotificationManager.cancel(NOTIFICATION_NAMESPACE, notificationId);
mNotifications.delete(notificationId);
updateSharedPreferencesEntry(notificationId, true);
}
}
/**
* Create a Bluetooth notification for the provided
* notificationId and bluetoothType.
* @param notificationId Unique id of the notification.
* @param bluetoothType Bluetooth type of the notification.
* @param url Url of the website interacting with Bluetooth devices.
* @param isIncognito Whether the notification comes from incognito mode.
*/
private void createNotification(
int notificationId, @BluetoothType int bluetoothType, String url, boolean isIncognito) {
Context appContext = ContextUtils.getApplicationContext();
NotificationWrapperBuilder builder =
NotificationWrapperBuilderFactory.createNotificationWrapperBuilder(
ChromeChannelDefinitions.ChannelId.BLUETOOTH,
new NotificationMetadata(
NotificationUmaTracker.SystemNotificationType.BLUETOOTH,
NOTIFICATION_NAMESPACE,
notificationId));
Intent tabIntent = mDelegate.createTrustedBringTabToFrontIntent(notificationId);
PendingIntentProvider contentIntent =
tabIntent == null
? null
: PendingIntentProvider.getActivity(
appContext, notificationId, tabIntent, 0);
builder.setAutoCancel(false)
.setOngoing(true)
.setLocalOnly(true)
.setContentIntent(contentIntent)
.setSmallIcon(getNotificationIconId(bluetoothType))
.setContentTitle(getNotificationTitleText(bluetoothType));
String contentText = null;
if (isIncognito) {
contentText =
appContext.getString(R.string.bluetooth_notification_content_text_incognito);
builder.setSubText(appContext.getString(R.string.notification_incognito_tab));
} else {
String urlForDisplay =
UrlFormatter.formatUrlForSecurityDisplay(
new GURL(url), SchemeDisplay.OMIT_HTTP_AND_HTTPS);
if (contentIntent == null) {
contentText = urlForDisplay;
} else {
contentText =
appContext.getString(
R.string.bluetooth_notification_content_text, urlForDisplay);
}
}
builder.setContentText(contentText);
NotificationWrapper notification = builder.buildWithBigTextStyle(contentText);
mNotificationManager.notify(notification);
mNotifications.put(notificationId, bluetoothType);
updateSharedPreferencesEntry(notificationId, false);
NotificationUmaTracker.getInstance()
.onNotificationShown(
NotificationUmaTracker.SystemNotificationType.BLUETOOTH,
notification.getNotification());
}
/**
* @param bluetoothType Bluetooth type of the notification.
* @return user-facing text for the provided bluetoothType.
*/
private String getNotificationTitleText(@BluetoothType int bluetoothType) {
int notificationContentTextId = 0;
if (bluetoothType == BluetoothType.IS_CONNECTED) {
notificationContentTextId = R.string.connected_to_bluetooth_device_notification_title;
} else if (bluetoothType == BluetoothType.IS_SCANNING) {
notificationContentTextId = R.string.scanning_for_bluetooth_devices_notification_title;
}
assert notificationContentTextId != 0;
return ContextUtils.getApplicationContext().getString(notificationContentTextId);
}
/**
* @param bluetoothType Bluetooth type of the notification.
* @return An icon id of the provided bluetoothType.
*/
private int getNotificationIconId(@BluetoothType int bluetoothType) {
int notificationIconId = 0;
if (bluetoothType == BluetoothType.IS_CONNECTED) {
notificationIconId = R.drawable.ic_bluetooth_connected;
} else if (bluetoothType == BluetoothType.IS_SCANNING) {
notificationIconId = R.drawable.gm_filled_bluetooth_searching_24;
}
return notificationIconId;
}
/**
* Update shared preferences entry with ids of the visible notifications.
* @param notificationId Id of the notification.
* @param remove Boolean describing if the notification was added or removed.
*/
private void updateSharedPreferencesEntry(int notificationId, boolean remove) {
Set<String> notificationIds =
new HashSet<>(
mSharedPreferences.readStringSet(
ChromePreferenceKeys.BLUETOOTH_NOTIFICATION_IDS, new HashSet<>()));
if (remove
&& !notificationIds.isEmpty()
&& notificationIds.contains(String.valueOf(notificationId))) {
notificationIds.remove(String.valueOf(notificationId));
} else if (!remove) {
notificationIds.add(String.valueOf(notificationId));
}
mSharedPreferences.writeStringSet(
ChromePreferenceKeys.BLUETOOTH_NOTIFICATION_IDS, notificationIds);
}
private static boolean shouldStartService(
Context context, @BluetoothType int bluetoothType, int notificationTabId) {
if (!ContentFeatureMap.isEnabled(
ContentFeatureList.WEB_BLUETOOTH_NEW_PERMISSIONS_BACKEND)) {
return false;
}
if (bluetoothType != BluetoothType.NO_BLUETOOTH) return true;
SharedPreferencesManager sharedPreferences = ChromeSharedPreferences.getInstance();
Set<String> notificationIds =
sharedPreferences.readStringSet(
ChromePreferenceKeys.BLUETOOTH_NOTIFICATION_IDS, null);
if (notificationIds == null || notificationIds.isEmpty()) return false;
return notificationIds.contains(String.valueOf(notificationTabId));
}
/**
* Send an intent to the bluetooth notification service to either create or destroy the
* notification identified by notificationTabId.
* @param context The activity context.
* @param service The bluetooth notification service class.
* @param notificationTabId The tab id.
* @param webContents The webContents for the tab. Used to query the Bluetooth state.
* @param url Url of the website interacting with Bluetooth devices.
* @param isIncognito Whether tab is in incognito mode.
*/
public static void updateBluetoothNotificationForTab(
Context context,
Class service,
int notificationTabId,
@Nullable WebContents webContents,
GURL url,
boolean isIncognito) {
@BluetoothType int bluetoothType = getBluetoothType(webContents);
if (!shouldStartService(context, bluetoothType, notificationTabId)) return;
Intent intent = new Intent(context, service);
intent.setAction(ACTION_BLUETOOTH_UPDATE);
intent.putExtra(NOTIFICATION_ID_EXTRA, notificationTabId);
intent.putExtra(NOTIFICATION_URL_EXTRA, url.getSpec());
intent.putExtra(NOTIFICATION_BLUETOOTH_TYPE_EXTRA, bluetoothType);
intent.putExtra(NOTIFICATION_IS_INCOGNITO, isIncognito);
context.startService(intent);
}
/**
* Clear any previous Bluetooth notifications.
* @param service The bluetooth notification service class.
*/
public static void clearBluetoothNotifications(Class service) {
if (!ContentFeatureMap.isEnabled(
ContentFeatureList.WEB_BLUETOOTH_NEW_PERMISSIONS_BACKEND)) {
return;
}
SharedPreferencesManager sharedPreferences = ChromeSharedPreferences.getInstance();
Set<String> notificationIds =
sharedPreferences.readStringSet(
ChromePreferenceKeys.BLUETOOTH_NOTIFICATION_IDS, null);
if (notificationIds == null || notificationIds.isEmpty()) return;
Context context = ContextUtils.getApplicationContext();
context.startService(new Intent(context, service));
}
/**
* @param webContents the webContents for the tab. Used to query the Bluetooth state.
* @return A constant identifying the kind of Bluetooth interaction.
*/
private static @BluetoothType int getBluetoothType(@Nullable WebContents webContents) {
if (webContents == null) {
return BluetoothType.NO_BLUETOOTH;
}
if (BluetoothBridge.isWebContentsScanningForBluetoothDevices(webContents)) {
return BluetoothType.IS_SCANNING;
}
if (BluetoothBridge.isWebContentsConnectedToBluetoothDevice(webContents)) {
return BluetoothType.IS_CONNECTED;
}
return BluetoothType.NO_BLUETOOTH;
}
}