blob: fb5a71c0b06fe2fe9300b1ab62d095e8356f1c17 [file] [log] [blame]
/*
* Copyright (C) 2022 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.systemui.volume;
import static android.app.PendingIntent.FLAG_IMMUTABLE;
import android.annotation.StringRes;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioManager;
import android.provider.Settings;
import android.util.Log;
import android.view.KeyEvent;
import android.view.WindowManager;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.messages.nano.SystemMessageProto;
import com.android.systemui.R;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.statusbar.phone.SystemUIDialog;
import com.android.systemui.util.NotificationChannels;
import com.android.systemui.util.concurrency.DelayableExecutor;
import dagger.assisted.Assisted;
import dagger.assisted.AssistedFactory;
import dagger.assisted.AssistedInject;
/**
* A class that implements the four Computed Sound Dose-related warnings defined in {@link AudioManager}:
* <ul>
* <li>{@link AudioManager#CSD_WARNING_DOSE_REACHED_1X}</li>
* <li>{@link AudioManager#CSD_WARNING_DOSE_REPEATED_5X}</li>
* <li>{@link AudioManager#CSD_WARNING_MOMENTARY_EXPOSURE}</li>
* </ul>
* Rather than basing volume safety messages on a fixed volume index, the CSD feature derives its
* warnings from the computation of the "sound dose". The dose computation is based on a
* frequency-dependent analysis of the audio signal which estimates how loud and potentially harmful
* the signal content is. This is combined with the volume attenuation/amplification applied to it
* and integrated over time to derive the dose exposure over a 7 day rolling window.
* <p>The UI behaviors implemented in this class are defined in IEC 62368 in "Safeguards against
* acoustic energy sources". The events that trigger those warnings originate in SoundDoseHelper
* which runs in the "audio" system_server service (see
* frameworks/base/services/core/java/com/android/server/audio/AudioService.java for the
* communication between the audio framework and the volume controller, and
* frameworks/base/services/core/java/com/android/server/audio/SoundDoseHelper.java for the
* communication between the native audio framework that implements the dose computation and the
* audio service.
*/
public class CsdWarningDialog extends SystemUIDialog
implements DialogInterface.OnDismissListener, DialogInterface.OnClickListener {
private static final String TAG = Util.logTag(CsdWarningDialog.class);
private static final int KEY_CONFIRM_ALLOWED_AFTER_MS = 1000; // milliseconds
// time after which action is taken when the user hasn't ack'd or dismissed the dialog
public static final int NO_ACTION_TIMEOUT_MS = 5000;
private final Context mContext;
private final AudioManager mAudioManager;
private final @AudioManager.CsdWarning int mCsdWarning;
private final Object mTimerLock = new Object();
/**
* Timer to keep track of how long the user has before an action (here volume reduction) is
* taken on their behalf.
*/
@GuardedBy("mTimerLock")
private Runnable mNoUserActionRunnable;
private Runnable mCancelScheduledNoUserActionRunnable = null;
private final DelayableExecutor mDelayableExecutor;
private NotificationManager mNotificationManager;
private Runnable mOnCleanup;
private long mShowTime;
/**
* To inject dependencies and allow for easier testing
*/
@AssistedFactory
public interface Factory {
/**
* Create a dialog object
*/
CsdWarningDialog create(int csdWarning, Runnable onCleanup);
}
@AssistedInject
public CsdWarningDialog(@Assisted @AudioManager.CsdWarning int csdWarning, Context context,
AudioManager audioManager, NotificationManager notificationManager,
@Background DelayableExecutor delayableExecutor, @Assisted Runnable onCleanup) {
super(context);
mCsdWarning = csdWarning;
mContext = context;
mAudioManager = audioManager;
mNotificationManager = notificationManager;
mOnCleanup = onCleanup;
mDelayableExecutor = delayableExecutor;
getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ERROR);
setShowForAllUsers(true);
setMessage(mContext.getString(getStringForWarning(csdWarning)));
setButton(DialogInterface.BUTTON_POSITIVE,
mContext.getString(R.string.csd_button_keep_listening), this);
setButton(DialogInterface.BUTTON_NEGATIVE,
mContext.getString(R.string.csd_button_lower_volume), this);
setOnDismissListener(this);
final IntentFilter filter = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
context.registerReceiver(mReceiver, filter,
Context.RECEIVER_EXPORTED_UNAUDITED);
if (csdWarning == AudioManager.CSD_WARNING_DOSE_REACHED_1X) {
mNoUserActionRunnable = () -> {
if (mCsdWarning == AudioManager.CSD_WARNING_DOSE_REACHED_1X) {
// unlike on the 5x dose repeat, level is only reduced to RS1 when the warning
// is not acknowledged quickly enough
mAudioManager.lowerVolumeToRs1();
sendNotification(/*for5XCsd=*/false);
}
};
} else {
mNoUserActionRunnable = null;
}
}
private void cleanUp() {
if (mOnCleanup != null) {
mOnCleanup.run();
}
}
@Override
public void show() {
if (mCsdWarning == AudioManager.CSD_WARNING_DOSE_REPEATED_5X) {
// only show a notification in case we reached 500% of dose
show5XNotification();
return;
}
super.show();
}
// NOT overriding onKeyDown as we're not allowing a dismissal on any key other than
// VOLUME_DOWN, and for this, we don't need to track if it's the start of a new
// key down -> up sequence
//@Override
//public boolean onKeyDown(int keyCode, KeyEvent event) {
// return super.onKeyDown(keyCode, event);
//}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
// never allow to raise volume
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
return true;
}
// VOLUME_DOWN will dismiss the dialog
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
&& (System.currentTimeMillis() - mShowTime) > KEY_CONFIRM_ALLOWED_AFTER_MS) {
Log.i(TAG, "Confirmed CSD exposure warning via VOLUME_DOWN");
dismiss();
}
return super.onKeyUp(keyCode, event);
}
@Override
public void onClick(DialogInterface dialog, int which) {
if (which == DialogInterface.BUTTON_NEGATIVE) {
Log.d(TAG, "Lower volume pressed for CSD warning " + mCsdWarning);
dismiss();
}
if (D.BUG) Log.d(TAG, "on click " + which);
}
@Override
protected void start() {
mShowTime = System.currentTimeMillis();
synchronized (mTimerLock) {
if (mNoUserActionRunnable != null) {
mCancelScheduledNoUserActionRunnable = mDelayableExecutor.executeDelayed(
mNoUserActionRunnable, NO_ACTION_TIMEOUT_MS);
}
}
}
@Override
protected void stop() {
synchronized (mTimerLock) {
if (mCancelScheduledNoUserActionRunnable != null) {
mCancelScheduledNoUserActionRunnable.run();
}
}
}
@Override
public void onDismiss(DialogInterface unused) {
if (mCsdWarning == AudioManager.CSD_WARNING_DOSE_REPEATED_5X) {
// level is always reduced to RS1 beyond the 5x dose
mAudioManager.lowerVolumeToRs1();
}
try {
mContext.unregisterReceiver(mReceiver);
} catch (IllegalArgumentException e) {
// Don't crash if the receiver has already been unregistered.
Log.e(TAG, "Error unregistering broadcast receiver", e);
}
cleanUp();
}
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(intent.getAction())) {
if (D.BUG) Log.d(TAG, "Received ACTION_CLOSE_SYSTEM_DIALOGS");
cancel();
cleanUp();
}
}
};
private @StringRes int getStringForWarning(@AudioManager.CsdWarning int csdWarning) {
switch (csdWarning) {
case AudioManager.CSD_WARNING_DOSE_REACHED_1X:
return com.android.internal.R.string.csd_dose_reached_warning;
case AudioManager.CSD_WARNING_MOMENTARY_EXPOSURE:
return com.android.internal.R.string.csd_momentary_exposure_warning;
}
Log.e(TAG, "Invalid CSD warning event " + csdWarning, new Exception());
return com.android.internal.R.string.csd_dose_reached_warning;
}
/** When 5X CSD is reached we lower the volume and show a notification. **/
private void show5XNotification() {
if (mCsdWarning != AudioManager.CSD_WARNING_DOSE_REPEATED_5X) {
Log.w(TAG, "Notification dose repeat 5x is not shown for " + mCsdWarning);
return;
}
mAudioManager.lowerVolumeToRs1();
sendNotification(/*for5XCsd=*/true);
}
/**
* In case user did not respond to the dialog, they still need to know volume was lowered.
*/
private void sendNotification(boolean for5XCsd) {
Intent intent = new Intent(Settings.ACTION_SOUND_SETTINGS);
PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, intent,
FLAG_IMMUTABLE);
String text = for5XCsd ? mContext.getString(R.string.csd_500_system_lowered_text)
: mContext.getString(R.string.csd_system_lowered_text);
String title = mContext.getString(R.string.csd_lowered_title);
Notification.Builder builder =
new Notification.Builder(mContext, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.hearing)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setStyle(new Notification.BigTextStyle().bigText(text))
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setLocalOnly(true)
.setAutoCancel(true)
.setCategory(Notification.CATEGORY_SYSTEM);
mNotificationManager.notify(SystemMessageProto.SystemMessage.NOTE_CSD_LOWER_AUDIO,
builder.build());
}
}