1 /*
2  * Copyright 2023 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.bluetooth.notification;
18 
19 import static java.util.Objects.requireNonNull;
20 
21 import android.app.Notification;
22 import android.app.NotificationChannel;
23 import android.app.NotificationManager;
24 import android.app.PendingIntent;
25 import android.app.Service;
26 import android.content.Intent;
27 import android.net.Uri;
28 import android.os.IBinder;
29 import android.provider.Settings;
30 import android.service.notification.StatusBarNotification;
31 import android.util.Log;
32 import android.util.Pair;
33 
34 import com.android.bluetooth.R;
35 import com.android.internal.messages.SystemMessageProto.SystemMessage;
36 
37 import java.time.LocalDateTime;
38 import java.time.ZoneId;
39 import java.util.Map;
40 
41 public class NotificationHelperService extends Service {
42     private static final String TAG = NotificationHelperService.class.getSimpleName();
43 
44     // Keeps track of whether wifi and bt remains on notification was shown
45     private static final String APM_WIFI_BT_NOTIFICATION = "apm_wifi_bt_notification";
46     // Keeps track of whether bt remains on notification was shown
47     private static final String APM_BT_NOTIFICATION = "apm_bt_notification";
48     // Keeps track of whether user enabling bt notification was shown
49     private static final String APM_BT_ENABLED_NOTIFICATION = "apm_bt_enabled_notification";
50     // Keeps track of whether auto on enabling bt notification was shown
51     private static final String AUTO_ON_BT_ENABLED_NOTIFICATION = "auto_on_bt_enabled_notification";
52 
53     private static final String NOTIFICATION_TAG = "com.android.bluetooth";
54     private static final String NOTIFICATION_CHANNEL = "notification_toggle_channel";
55     private static final int NOTIFICATION_GROUP = R.string.bluetooth_notification_group;
56 
57     private static final String NOTIFICATION_ACTION =
58             "android.bluetooth.notification.action.SEND_TOGGLE_NOTIFICATION";
59     private static final String NOTIFICATION_EXTRA =
60             "android.bluetooth.notification.extra.NOTIFICATION_REASON";
61 
62     private static final Map<String, Pair<Integer /* titleId */, Integer /* messageId */>>
63             NOTIFICATION_MAP =
64                     Map.of(
65                             APM_WIFI_BT_NOTIFICATION,
66                             Pair.create(
67                                     R.string.bluetooth_and_wifi_stays_on_title,
68                                     R.string.bluetooth_and_wifi_stays_on_message),
69                             APM_BT_NOTIFICATION,
70                             Pair.create(
71                                     R.string.bluetooth_stays_on_title,
72                                     R.string.bluetooth_stays_on_message),
73                             APM_BT_ENABLED_NOTIFICATION,
74                             Pair.create(
75                                     R.string.bluetooth_enabled_apm_title,
76                                     R.string.bluetooth_enabled_apm_message),
77                             AUTO_ON_BT_ENABLED_NOTIFICATION,
78                             Pair.create(
79                                     R.string.bluetooth_enabled_auto_on_title,
80                                     R.string.bluetooth_enabled_auto_on_message));
81 
82     @Override
onBind(Intent intent)83     public IBinder onBind(Intent intent) {
84         return null; // This is not a bound service
85     }
86 
87     @Override
onStartCommand(Intent intent, int flags, int startId)88     public int onStartCommand(Intent intent, int flags, int startId) {
89         switch (intent.getAction()) {
90             case NOTIFICATION_ACTION -> {
91                 sendToggleNotification(intent.getStringExtra(NOTIFICATION_EXTRA));
92             }
93         }
94         return Service.START_NOT_STICKY;
95     }
96 
sendToggleNotification(String notificationReason)97     private void sendToggleNotification(String notificationReason) {
98         String logHeader = "sendToggleNotification(" + notificationReason + "): ";
99         Pair<Integer, Integer> notificationContent = NOTIFICATION_MAP.get(notificationReason);
100         if (notificationContent == null) {
101             Log.e(TAG, logHeader + "unknown action");
102             return;
103         }
104 
105         if (!shouldDisplayNotification(notificationReason)) {
106             return;
107         }
108 
109         NotificationManager notificationManager =
110                 requireNonNull(getSystemService(NotificationManager.class));
111         String tag = NOTIFICATION_TAG + "/" + notificationReason;
112         for (StatusBarNotification notification : notificationManager.getActiveNotifications()) {
113             if (tag.equals(notification.getTag())) {
114                 notificationManager.cancel(tag, notification.getId());
115             }
116         }
117 
118         notificationManager.createNotificationChannel(
119                 new NotificationChannel(
120                         NOTIFICATION_CHANNEL,
121                         getString(NOTIFICATION_GROUP),
122                         NotificationManager.IMPORTANCE_LOW));
123 
124         String title = getString(notificationContent.first);
125         String message = getString(notificationContent.second);
126 
127         Notification.Builder builder =
128                 new Notification.Builder(this, NOTIFICATION_CHANNEL)
129                         .setAutoCancel(true)
130                         .setLocalOnly(true)
131                         .setContentTitle(title)
132                         .setContentText(message)
133                         .setVisibility(Notification.VISIBILITY_PUBLIC)
134                         .setStyle(new Notification.BigTextStyle().bigText(message))
135                         .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth);
136 
137         if (!notificationReason.equals(AUTO_ON_BT_ENABLED_NOTIFICATION)) {
138             // Do not display airplane link when the notification is due to auto_on feature
139             String helpLinkUrl = getString(R.string.config_apmLearnMoreLink);
140             builder.setContentIntent(
141                     PendingIntent.getActivity(
142                             this,
143                             PendingIntent.FLAG_UPDATE_CURRENT,
144                             new Intent(Intent.ACTION_VIEW)
145                                     .setData(Uri.parse(helpLinkUrl))
146                                     .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
147                             PendingIntent.FLAG_IMMUTABLE));
148         } else {
149             // Open the setting page when the notification is clicked
150             builder.setContentIntent(
151                     PendingIntent.getActivity(
152                             this,
153                             PendingIntent.FLAG_UPDATE_CURRENT,
154                             new Intent("android.settings.BLUETOOTH_DASHBOARD_SETTINGS"),
155                             PendingIntent.FLAG_IMMUTABLE));
156         }
157 
158         notificationManager.notify(
159                 tag, SystemMessage.ID.NOTE_BT_APM_NOTIFICATION_VALUE, builder.build());
160     }
161 
shouldDisplayNotification(String countKey)162     private boolean shouldDisplayNotification(String countKey) {
163         final LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
164         final String dateKey = countKey + "_date";
165         final String date = Settings.Secure.getString(getContentResolver(), dateKey);
166         final int countShown = Settings.Secure.getInt(getContentResolver(), countKey, 0);
167 
168         // The notification is always displayed the first time and if it has been at least…:
169         //  * … 1 week since the first display (aka recurring only once)
170         //  * … 6 months since the last display (aka recurring forever)
171 
172         LocalDateTime savedDate = null;
173         if (date != null) {
174             savedDate = LocalDateTime.parse(date);
175             if ((countShown == 1 && now.isBefore(savedDate.plusWeeks(1)))
176                     || now.isBefore(savedDate.plusMonths(6))) {
177                 Log.i(
178                         TAG,
179                         ("shouldDisplayNotification(" + countKey + "): Notification discarded.")
180                                 + (" countShown=" + countShown)
181                                 + (" savedDate=" + savedDate));
182                 return false;
183             }
184         }
185 
186         Settings.Secure.putInt(getContentResolver(), countKey, Math.min(3, countShown + 1));
187         Settings.Secure.putString(getContentResolver(), dateKey, now.toString());
188         Log.i(
189                 TAG,
190                 ("shouldDisplayNotification(" + countKey + "): Notification is being shown.")
191                         + (" countShown=" + countShown)
192                         + (" savedDate=" + savedDate));
193         return true;
194     }
195 }
196