/* * 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.dagger.qualifiers.Background; import com.android.systemui.res.R; 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 three Computed Sound Dose-related warnings defined in * {@link AudioManager}: *
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(); dismissCsdDialog(); 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); mAudioManager.lowerVolumeToRs1(); 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) { dismissCsdDialog(); } private void dismissCsdDialog() { 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()); } }