1 /*
2  * Copyright (C) 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.server.devicestate;
18 
19 import static android.provider.Settings.ACTION_BATTERY_SAVER_SETTINGS;
20 
21 import android.annotation.DrawableRes;
22 import android.annotation.NonNull;
23 import android.annotation.Nullable;
24 import android.app.Notification;
25 import android.app.NotificationChannel;
26 import android.app.NotificationManager;
27 import android.app.PendingIntent;
28 import android.content.BroadcastReceiver;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.IntentFilter;
32 import android.content.pm.ApplicationInfo;
33 import android.content.pm.PackageManager;
34 import android.hardware.devicestate.DeviceStateManager;
35 import android.os.Handler;
36 import android.util.Slog;
37 import android.util.SparseArray;
38 
39 import com.android.internal.R;
40 import com.android.internal.annotations.GuardedBy;
41 import com.android.internal.annotations.VisibleForTesting;
42 
43 import java.util.Locale;
44 
45 /**
46  * Manages the user-visible device state notifications.
47  */
48 class DeviceStateNotificationController extends BroadcastReceiver {
49     private static final String TAG = "DeviceStateNotificationController";
50 
51     @VisibleForTesting static final String INTENT_ACTION_CANCEL_STATE =
52             "com.android.server.devicestate.INTENT_ACTION_CANCEL_STATE";
53     @VisibleForTesting static final int NOTIFICATION_ID = 1;
54     @VisibleForTesting static final String CHANNEL_ID = "DeviceStateManager";
55     @VisibleForTesting static final String NOTIFICATION_TAG = "DeviceStateManager";
56 
57     private final Context mContext;
58     private final Handler mHandler;
59     private final NotificationManager mNotificationManager;
60     private final PackageManager mPackageManager;
61 
62     // The callback when a device state is requested to be canceled.
63     private final Runnable mCancelStateRunnable;
64 
65     private final NotificationInfoProvider mNotificationInfoProvider;
66 
DeviceStateNotificationController(@onNull Context context, @NonNull Handler handler, @NonNull Runnable cancelStateRunnable)67     DeviceStateNotificationController(@NonNull Context context, @NonNull Handler handler,
68             @NonNull Runnable cancelStateRunnable) {
69         this(context, handler, cancelStateRunnable, new NotificationInfoProvider(context),
70                 context.getPackageManager(), context.getSystemService(NotificationManager.class));
71     }
72 
73     @VisibleForTesting
DeviceStateNotificationController( @onNull Context context, @NonNull Handler handler, @NonNull Runnable cancelStateRunnable, @NonNull NotificationInfoProvider notificationInfoProvider, @NonNull PackageManager packageManager, @NonNull NotificationManager notificationManager)74     DeviceStateNotificationController(
75             @NonNull Context context, @NonNull Handler handler,
76             @NonNull Runnable cancelStateRunnable,
77             @NonNull NotificationInfoProvider notificationInfoProvider,
78             @NonNull PackageManager packageManager,
79             @NonNull NotificationManager notificationManager) {
80         mContext = context;
81         mHandler = handler;
82         mCancelStateRunnable = cancelStateRunnable;
83         mNotificationInfoProvider = notificationInfoProvider;
84         mPackageManager = packageManager;
85         mNotificationManager = notificationManager;
86         mContext.registerReceiver(
87                 this,
88                 new IntentFilter(INTENT_ACTION_CANCEL_STATE),
89                 android.Manifest.permission.CONTROL_DEVICE_STATE,
90                 mHandler,
91                 Context.RECEIVER_NOT_EXPORTED);
92     }
93 
94     /**
95      * Displays the ongoing notification indicating that the device state is active. Does nothing if
96      * the state does not have an active notification.
97      *
98      * @param state the active device state identifier.
99      * @param requestingAppUid the uid of the requesting app used to retrieve the app name.
100      */
showStateActiveNotificationIfNeeded(int state, int requestingAppUid)101     void showStateActiveNotificationIfNeeded(int state, int requestingAppUid) {
102         NotificationInfo info = getNotificationInfos().get(state);
103         if (info == null || !info.hasActiveNotification()) {
104             return;
105         }
106         String requesterApplicationLabel = getApplicationLabel(requestingAppUid);
107         if (requesterApplicationLabel != null) {
108             final Intent intent = new Intent(INTENT_ACTION_CANCEL_STATE)
109                     .setPackage(mContext.getPackageName());
110             final PendingIntent pendingIntent = PendingIntent.getBroadcast(
111                     mContext, 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE);
112 
113             showNotification(
114                     info.name, info.activeNotificationTitle,
115                     String.format(info.activeNotificationContent, requesterApplicationLabel),
116                     true /* ongoing */, R.drawable.ic_dual_screen,
117                     pendingIntent,
118                     mContext.getString(R.string.device_state_notification_turn_off_button)
119             );
120         } else {
121             Slog.e(TAG, "Cannot determine the requesting app name when showing state active "
122                     + "notification. uid=" + requestingAppUid + ", state=" + state);
123         }
124     }
125 
126     /**
127      * Displays the notification indicating that the device state is canceled due to thermal
128      * critical condition. Does nothing if the state does not have a thermal critical notification.
129      *
130      * @param state the identifier of the device state being canceled.
131      */
showThermalCriticalNotificationIfNeeded(int state)132     void showThermalCriticalNotificationIfNeeded(int state) {
133         NotificationInfo info = getNotificationInfos().get(state);
134         if (info == null || !info.hasThermalCriticalNotification()) {
135             return;
136         }
137         showNotification(
138                 info.name, info.thermalCriticalNotificationTitle,
139                 info.thermalCriticalNotificationContent, false /* ongoing */,
140                 R.drawable.ic_thermostat,
141                 null /* pendingIntent */,
142                 null /* actionText */
143         );
144     }
145 
146     /**
147      * Displays the notification indicating that the device state is canceled due to power
148      * save mode being enabled. Does nothing if the state does not have a power save mode
149      * notification.
150      *
151      * @param state the identifier of the device state being canceled.
152      */
showPowerSaveNotificationIfNeeded(int state)153     void showPowerSaveNotificationIfNeeded(int state) {
154         NotificationInfo info = getNotificationInfos().get(state);
155         if (info == null || !info.hasPowerSaveModeNotification()) {
156             return;
157         }
158         final Intent intent = new Intent(ACTION_BATTERY_SAVER_SETTINGS);
159         final PendingIntent pendingIntent = PendingIntent.getActivity(
160                 mContext, 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE);
161         showNotification(
162                 info.name, info.powerSaveModeNotificationTitle,
163                 info.powerSaveModeNotificationContent, false /* ongoing */,
164                 R.drawable.ic_thermostat,
165                 pendingIntent,
166                 mContext.getString(R.string.device_state_notification_settings_button)
167         );
168     }
169 
170     /**
171      * Cancels the notification of the corresponding device state.
172      *
173      * @param state the device state identifier.
174      */
cancelNotification(int state)175     void cancelNotification(int state) {
176         if (getNotificationInfos().get(state) == null) {
177             return;
178         }
179         mHandler.post(() -> mNotificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID));
180     }
181 
182     @Override
onReceive(@onNull Context context, @Nullable Intent intent)183     public void onReceive(@NonNull Context context, @Nullable Intent intent) {
184         if (intent != null) {
185             if (INTENT_ACTION_CANCEL_STATE.equals(intent.getAction())) {
186                 mCancelStateRunnable.run();
187             }
188         }
189     }
190 
191     /**
192      * Displays a notification with the specified name, title, and content.
193      *
194      * @param name the name of the notification.
195      * @param title the title of the notification.
196      * @param content the content of the notification.
197      * @param ongoing if true, display an ongoing (sticky) notification with a turn off button.
198      */
showNotification( @onNull String name, @NonNull String title, @NonNull String content, boolean ongoing, @DrawableRes int iconRes, @Nullable PendingIntent pendingIntent, @Nullable String actionText)199     private void showNotification(
200             @NonNull String name, @NonNull String title, @NonNull String content, boolean ongoing,
201             @DrawableRes int iconRes,
202             @Nullable PendingIntent pendingIntent, @Nullable String actionText) {
203         final NotificationChannel channel = new NotificationChannel(
204                 CHANNEL_ID, name, NotificationManager.IMPORTANCE_HIGH);
205         final Notification.Builder builder = new Notification.Builder(mContext, CHANNEL_ID)
206                 .setSmallIcon(iconRes)
207                 .setContentTitle(title)
208                 .setContentText(content)
209                 .setSubText(name)
210                 .setLocalOnly(true)
211                 .setOngoing(ongoing)
212                 .setCategory(Notification.CATEGORY_SYSTEM);
213 
214         if (pendingIntent != null && actionText != null) {
215             final Notification.Action action = new Notification.Action.Builder(
216                     null /* icon */,
217                     actionText,
218                     pendingIntent)
219                     .build();
220             builder.addAction(action);
221         }
222 
223         mHandler.post(() -> {
224             mNotificationManager.createNotificationChannel(channel);
225             mNotificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, builder.build());
226         });
227     }
228 
getNotificationInfos()229     private SparseArray<NotificationInfo> getNotificationInfos() {
230         Locale locale = mContext.getResources().getConfiguration().getLocales().get(0);
231         return mNotificationInfoProvider.getNotificationInfos(locale);
232     }
233 
234     @VisibleForTesting
235     public static class NotificationInfoProvider {
236         @NonNull
237         private final Context mContext;
238         private final Object mLock = new Object();
239 
240         @GuardedBy("mLock")
241         @Nullable
242         private SparseArray<NotificationInfo> mCachedNotificationInfos;
243 
244         @GuardedBy("mLock")
245         @Nullable
246         @VisibleForTesting
247         Locale mCachedLocale;
248 
NotificationInfoProvider(@onNull Context context)249         NotificationInfoProvider(@NonNull Context context) {
250             mContext = context;
251         }
252 
253         /**
254          * Loads the resources for the notifications. The device state identifiers and strings are
255          * stored in arrays. All the string arrays must have the same length and same order as the
256          * identifier array.
257          */
258         @NonNull
getNotificationInfos(@onNull Locale locale)259         public SparseArray<NotificationInfo> getNotificationInfos(@NonNull Locale locale) {
260             synchronized (mLock) {
261                 if (!locale.equals(mCachedLocale)) {
262                     refreshNotificationInfos(locale);
263                 }
264                 return mCachedNotificationInfos;
265             }
266         }
267 
268 
269         @VisibleForTesting
getCachedLocale()270         Locale getCachedLocale() {
271             synchronized (mLock) {
272                 return mCachedLocale;
273             }
274         }
275 
276         @VisibleForTesting
refreshNotificationInfos(Locale locale)277         public void refreshNotificationInfos(Locale locale) {
278             synchronized (mLock) {
279                 mCachedLocale = locale;
280                 mCachedNotificationInfos = loadNotificationInfos();
281             }
282         }
283 
284         @VisibleForTesting
loadNotificationInfos()285         public SparseArray<NotificationInfo> loadNotificationInfos() {
286             final SparseArray<NotificationInfo> notificationInfos = new SparseArray<>();
287 
288             final int[] stateIdentifiers =
289                     mContext.getResources().getIntArray(
290                             R.array.device_state_notification_state_identifiers);
291             final String[] names =
292                     mContext.getResources().getStringArray(R.array.device_state_notification_names);
293             final String[] activeNotificationTitles =
294                     mContext.getResources().getStringArray(
295                             R.array.device_state_notification_active_titles);
296             final String[] activeNotificationContents =
297                     mContext.getResources().getStringArray(
298                             R.array.device_state_notification_active_contents);
299             final String[] thermalCriticalNotificationTitles =
300                     mContext.getResources().getStringArray(
301                             R.array.device_state_notification_thermal_titles);
302             final String[] thermalCriticalNotificationContents =
303                     mContext.getResources().getStringArray(
304                             R.array.device_state_notification_thermal_contents);
305             final String[] powerSaveModeNotificationTitles =
306                     mContext.getResources().getStringArray(
307                             R.array.device_state_notification_power_save_titles);
308             final String[] powerSaveModeNotificationContents =
309                     mContext.getResources().getStringArray(
310                             R.array.device_state_notification_power_save_contents);
311 
312             if (stateIdentifiers.length != names.length
313                     || stateIdentifiers.length != activeNotificationTitles.length
314                     || stateIdentifiers.length != activeNotificationContents.length
315                     || stateIdentifiers.length != thermalCriticalNotificationTitles.length
316                     || stateIdentifiers.length != thermalCriticalNotificationContents.length
317                     || stateIdentifiers.length != powerSaveModeNotificationTitles.length
318                     || stateIdentifiers.length != powerSaveModeNotificationContents.length
319             ) {
320                 throw new IllegalStateException(
321                         "The length of state identifiers and notification texts must match!");
322             }
323 
324             for (int i = 0; i < stateIdentifiers.length; i++) {
325                 int identifier = stateIdentifiers[i];
326                 if (identifier == DeviceStateManager.INVALID_DEVICE_STATE_IDENTIFIER) {
327                     continue;
328                 }
329 
330                 notificationInfos.put(
331                         identifier,
332                         new NotificationInfo(
333                                 names[i],
334                                 activeNotificationTitles[i],
335                                 activeNotificationContents[i],
336                                 thermalCriticalNotificationTitles[i],
337                                 thermalCriticalNotificationContents[i],
338                                 powerSaveModeNotificationTitles[i],
339                                 powerSaveModeNotificationContents[i])
340                 );
341             }
342             return notificationInfos;
343         }
344     }
345 
346     /**
347      * A helper function to get app name (label) using the app uid.
348      *
349      * @param uid the uid of the app.
350      * @return app name (label) if found, or null otherwise.
351      */
352     @Nullable
getApplicationLabel(int uid)353     private String getApplicationLabel(int uid) {
354         String packageName = mPackageManager.getNameForUid(uid);
355         try {
356             ApplicationInfo appInfo = mPackageManager.getApplicationInfo(
357                     packageName, PackageManager.ApplicationInfoFlags.of(0));
358             return appInfo.loadLabel(mPackageManager).toString();
359         } catch (PackageManager.NameNotFoundException e) {
360             return null;
361         }
362     }
363 
364     /**
365      * A data class storing string resources of the notification of a device state.
366      */
367     @VisibleForTesting
368     static class NotificationInfo {
369         public final String name;
370         public final String activeNotificationTitle;
371         public final String activeNotificationContent;
372         public final String thermalCriticalNotificationTitle;
373         public final String thermalCriticalNotificationContent;
374         public final String powerSaveModeNotificationTitle;
375         public final String powerSaveModeNotificationContent;
376 
NotificationInfo(String name, String activeNotificationTitle, String activeNotificationContent, String thermalCriticalNotificationTitle, String thermalCriticalNotificationContent, String powerSaveModeNotificationTitle, String powerSaveModeNotificationContent)377         NotificationInfo(String name, String activeNotificationTitle,
378                 String activeNotificationContent, String thermalCriticalNotificationTitle,
379                 String thermalCriticalNotificationContent, String powerSaveModeNotificationTitle,
380                 String powerSaveModeNotificationContent) {
381 
382             this.name = name;
383             this.activeNotificationTitle = activeNotificationTitle;
384             this.activeNotificationContent = activeNotificationContent;
385             this.thermalCriticalNotificationTitle = thermalCriticalNotificationTitle;
386             this.thermalCriticalNotificationContent = thermalCriticalNotificationContent;
387             this.powerSaveModeNotificationTitle = powerSaveModeNotificationTitle;
388             this.powerSaveModeNotificationContent = powerSaveModeNotificationContent;
389         }
390 
hasActiveNotification()391         boolean hasActiveNotification() {
392             return activeNotificationTitle != null && activeNotificationTitle.length() > 0;
393         }
394 
hasThermalCriticalNotification()395         boolean hasThermalCriticalNotification() {
396             return thermalCriticalNotificationTitle != null
397                     && thermalCriticalNotificationTitle.length() > 0;
398         }
399 
hasPowerSaveModeNotification()400         boolean hasPowerSaveModeNotification() {
401             return powerSaveModeNotificationTitle != null
402                     && powerSaveModeNotificationTitle.length() > 0;
403         }
404     }
405 }
406