blob: f9aefd0535d0e4e767450c6de6990afe9c3fa962 [file] [log] [blame]
/*
* Copyright (C) 2023 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.android.server.devicestate;
import static android.provider.Settings.ACTION_BATTERY_SAVER_SETTINGS;
import android.annotation.DrawableRes;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.hardware.devicestate.DeviceStateManager;
import android.os.Handler;
import android.util.Slog;
import android.util.SparseArray;
import com.android.internal.R;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import java.util.Locale;
/**
* Manages the user-visible device state notifications.
*/
class DeviceStateNotificationController extends BroadcastReceiver {
private static final String TAG = "DeviceStateNotificationController";
@VisibleForTesting static final String INTENT_ACTION_CANCEL_STATE =
"com.android.server.devicestate.INTENT_ACTION_CANCEL_STATE";
@VisibleForTesting static final int NOTIFICATION_ID = 1;
@VisibleForTesting static final String CHANNEL_ID = "DeviceStateManager";
@VisibleForTesting static final String NOTIFICATION_TAG = "DeviceStateManager";
private final Context mContext;
private final Handler mHandler;
private final NotificationManager mNotificationManager;
private final PackageManager mPackageManager;
// The callback when a device state is requested to be canceled.
private final Runnable mCancelStateRunnable;
private final NotificationInfoProvider mNotificationInfoProvider;
DeviceStateNotificationController(@NonNull Context context, @NonNull Handler handler,
@NonNull Runnable cancelStateRunnable) {
this(context, handler, cancelStateRunnable, new NotificationInfoProvider(context),
context.getPackageManager(), context.getSystemService(NotificationManager.class));
}
@VisibleForTesting
DeviceStateNotificationController(
@NonNull Context context, @NonNull Handler handler,
@NonNull Runnable cancelStateRunnable,
@NonNull NotificationInfoProvider notificationInfoProvider,
@NonNull PackageManager packageManager,
@NonNull NotificationManager notificationManager) {
mContext = context;
mHandler = handler;
mCancelStateRunnable = cancelStateRunnable;
mNotificationInfoProvider = notificationInfoProvider;
mPackageManager = packageManager;
mNotificationManager = notificationManager;
mContext.registerReceiver(
this,
new IntentFilter(INTENT_ACTION_CANCEL_STATE),
android.Manifest.permission.CONTROL_DEVICE_STATE,
mHandler,
Context.RECEIVER_NOT_EXPORTED);
}
/**
* Displays the ongoing notification indicating that the device state is active. Does nothing if
* the state does not have an active notification.
*
* @param state the active device state identifier.
* @param requestingAppUid the uid of the requesting app used to retrieve the app name.
*/
void showStateActiveNotificationIfNeeded(int state, int requestingAppUid) {
NotificationInfo info = getNotificationInfos().get(state);
if (info == null || !info.hasActiveNotification()) {
return;
}
String requesterApplicationLabel = getApplicationLabel(requestingAppUid);
if (requesterApplicationLabel != null) {
final Intent intent = new Intent(INTENT_ACTION_CANCEL_STATE)
.setPackage(mContext.getPackageName());
final PendingIntent pendingIntent = PendingIntent.getBroadcast(
mContext, 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE);
showNotification(
info.name, info.activeNotificationTitle,
String.format(info.activeNotificationContent, requesterApplicationLabel),
true /* ongoing */, R.drawable.ic_dual_screen,
pendingIntent,
mContext.getString(R.string.device_state_notification_turn_off_button)
);
} else {
Slog.e(TAG, "Cannot determine the requesting app name when showing state active "
+ "notification. uid=" + requestingAppUid + ", state=" + state);
}
}
/**
* Displays the notification indicating that the device state is canceled due to thermal
* critical condition. Does nothing if the state does not have a thermal critical notification.
*
* @param state the identifier of the device state being canceled.
*/
void showThermalCriticalNotificationIfNeeded(int state) {
NotificationInfo info = getNotificationInfos().get(state);
if (info == null || !info.hasThermalCriticalNotification()) {
return;
}
showNotification(
info.name, info.thermalCriticalNotificationTitle,
info.thermalCriticalNotificationContent, false /* ongoing */,
R.drawable.ic_thermostat,
null /* pendingIntent */,
null /* actionText */
);
}
/**
* Displays the notification indicating that the device state is canceled due to power
* save mode being enabled. Does nothing if the state does not have a power save mode
* notification.
*
* @param state the identifier of the device state being canceled.
*/
void showPowerSaveNotificationIfNeeded(int state) {
NotificationInfo info = getNotificationInfos().get(state);
if (info == null || !info.hasPowerSaveModeNotification()) {
return;
}
final Intent intent = new Intent(ACTION_BATTERY_SAVER_SETTINGS);
final PendingIntent pendingIntent = PendingIntent.getActivity(
mContext, 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE);
showNotification(
info.name, info.powerSaveModeNotificationTitle,
info.powerSaveModeNotificationContent, false /* ongoing */,
R.drawable.ic_thermostat,
pendingIntent,
mContext.getString(R.string.device_state_notification_settings_button)
);
}
/**
* Cancels the notification of the corresponding device state.
*
* @param state the device state identifier.
*/
void cancelNotification(int state) {
if (getNotificationInfos().get(state) == null) {
return;
}
mHandler.post(() -> mNotificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID));
}
@Override
public void onReceive(@NonNull Context context, @Nullable Intent intent) {
if (intent != null) {
if (INTENT_ACTION_CANCEL_STATE.equals(intent.getAction())) {
mCancelStateRunnable.run();
}
}
}
/**
* Displays a notification with the specified name, title, and content.
*
* @param name the name of the notification.
* @param title the title of the notification.
* @param content the content of the notification.
* @param ongoing if true, display an ongoing (sticky) notification with a turn off button.
*/
private void showNotification(
@NonNull String name, @NonNull String title, @NonNull String content, boolean ongoing,
@DrawableRes int iconRes,
@Nullable PendingIntent pendingIntent, @Nullable String actionText) {
final NotificationChannel channel = new NotificationChannel(
CHANNEL_ID, name, NotificationManager.IMPORTANCE_HIGH);
final Notification.Builder builder = new Notification.Builder(mContext, CHANNEL_ID)
.setSmallIcon(iconRes)
.setContentTitle(title)
.setContentText(content)
.setSubText(name)
.setLocalOnly(true)
.setOngoing(ongoing)
.setCategory(Notification.CATEGORY_SYSTEM);
if (pendingIntent != null && actionText != null) {
final Notification.Action action = new Notification.Action.Builder(
null /* icon */,
actionText,
pendingIntent)
.build();
builder.addAction(action);
}
mHandler.post(() -> {
mNotificationManager.createNotificationChannel(channel);
mNotificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, builder.build());
});
}
private SparseArray<NotificationInfo> getNotificationInfos() {
Locale locale = mContext.getResources().getConfiguration().getLocales().get(0);
return mNotificationInfoProvider.getNotificationInfos(locale);
}
@VisibleForTesting
public static class NotificationInfoProvider {
@NonNull
private final Context mContext;
private final Object mLock = new Object();
@GuardedBy("mLock")
@Nullable
private SparseArray<NotificationInfo> mCachedNotificationInfos;
@GuardedBy("mLock")
@Nullable
@VisibleForTesting
Locale mCachedLocale;
NotificationInfoProvider(@NonNull Context context) {
mContext = context;
}
/**
* Loads the resources for the notifications. The device state identifiers and strings are
* stored in arrays. All the string arrays must have the same length and same order as the
* identifier array.
*/
@NonNull
public SparseArray<NotificationInfo> getNotificationInfos(@NonNull Locale locale) {
synchronized (mLock) {
if (!locale.equals(mCachedLocale)) {
refreshNotificationInfos(locale);
}
return mCachedNotificationInfos;
}
}
@VisibleForTesting
Locale getCachedLocale() {
synchronized (mLock) {
return mCachedLocale;
}
}
@VisibleForTesting
public void refreshNotificationInfos(Locale locale) {
synchronized (mLock) {
mCachedLocale = locale;
mCachedNotificationInfos = loadNotificationInfos();
}
}
@VisibleForTesting
public SparseArray<NotificationInfo> loadNotificationInfos() {
final SparseArray<NotificationInfo> notificationInfos = new SparseArray<>();
final int[] stateIdentifiers =
mContext.getResources().getIntArray(
R.array.device_state_notification_state_identifiers);
final String[] names =
mContext.getResources().getStringArray(R.array.device_state_notification_names);
final String[] activeNotificationTitles =
mContext.getResources().getStringArray(
R.array.device_state_notification_active_titles);
final String[] activeNotificationContents =
mContext.getResources().getStringArray(
R.array.device_state_notification_active_contents);
final String[] thermalCriticalNotificationTitles =
mContext.getResources().getStringArray(
R.array.device_state_notification_thermal_titles);
final String[] thermalCriticalNotificationContents =
mContext.getResources().getStringArray(
R.array.device_state_notification_thermal_contents);
final String[] powerSaveModeNotificationTitles =
mContext.getResources().getStringArray(
R.array.device_state_notification_power_save_titles);
final String[] powerSaveModeNotificationContents =
mContext.getResources().getStringArray(
R.array.device_state_notification_power_save_contents);
if (stateIdentifiers.length != names.length
|| stateIdentifiers.length != activeNotificationTitles.length
|| stateIdentifiers.length != activeNotificationContents.length
|| stateIdentifiers.length != thermalCriticalNotificationTitles.length
|| stateIdentifiers.length != thermalCriticalNotificationContents.length
|| stateIdentifiers.length != powerSaveModeNotificationTitles.length
|| stateIdentifiers.length != powerSaveModeNotificationContents.length
) {
throw new IllegalStateException(
"The length of state identifiers and notification texts must match!");
}
for (int i = 0; i < stateIdentifiers.length; i++) {
int identifier = stateIdentifiers[i];
if (identifier == DeviceStateManager.INVALID_DEVICE_STATE) {
continue;
}
notificationInfos.put(
identifier,
new NotificationInfo(
names[i],
activeNotificationTitles[i],
activeNotificationContents[i],
thermalCriticalNotificationTitles[i],
thermalCriticalNotificationContents[i],
powerSaveModeNotificationTitles[i],
powerSaveModeNotificationContents[i])
);
}
return notificationInfos;
}
}
/**
* A helper function to get app name (label) using the app uid.
*
* @param uid the uid of the app.
* @return app name (label) if found, or null otherwise.
*/
@Nullable
private String getApplicationLabel(int uid) {
String packageName = mPackageManager.getNameForUid(uid);
try {
ApplicationInfo appInfo = mPackageManager.getApplicationInfo(
packageName, PackageManager.ApplicationInfoFlags.of(0));
return appInfo.loadLabel(mPackageManager).toString();
} catch (PackageManager.NameNotFoundException e) {
return null;
}
}
/**
* A data class storing string resources of the notification of a device state.
*/
@VisibleForTesting
static class NotificationInfo {
public final String name;
public final String activeNotificationTitle;
public final String activeNotificationContent;
public final String thermalCriticalNotificationTitle;
public final String thermalCriticalNotificationContent;
public final String powerSaveModeNotificationTitle;
public final String powerSaveModeNotificationContent;
NotificationInfo(String name, String activeNotificationTitle,
String activeNotificationContent, String thermalCriticalNotificationTitle,
String thermalCriticalNotificationContent, String powerSaveModeNotificationTitle,
String powerSaveModeNotificationContent) {
this.name = name;
this.activeNotificationTitle = activeNotificationTitle;
this.activeNotificationContent = activeNotificationContent;
this.thermalCriticalNotificationTitle = thermalCriticalNotificationTitle;
this.thermalCriticalNotificationContent = thermalCriticalNotificationContent;
this.powerSaveModeNotificationTitle = powerSaveModeNotificationTitle;
this.powerSaveModeNotificationContent = powerSaveModeNotificationContent;
}
boolean hasActiveNotification() {
return activeNotificationTitle != null && activeNotificationTitle.length() > 0;
}
boolean hasThermalCriticalNotification() {
return thermalCriticalNotificationTitle != null
&& thermalCriticalNotificationTitle.length() > 0;
}
boolean hasPowerSaveModeNotification() {
return powerSaveModeNotificationTitle != null
&& powerSaveModeNotificationTitle.length() > 0;
}
}
}