1 /* 2 * Copyright (C) 2022 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.systemui.volume; 18 19 import static android.app.PendingIntent.FLAG_IMMUTABLE; 20 21 import android.annotation.StringRes; 22 import android.app.Notification; 23 import android.app.NotificationManager; 24 import android.app.PendingIntent; 25 import android.content.BroadcastReceiver; 26 import android.content.Context; 27 import android.content.DialogInterface; 28 import android.content.Intent; 29 import android.content.IntentFilter; 30 import android.media.AudioManager; 31 import android.provider.Settings; 32 import android.util.Log; 33 import android.view.KeyEvent; 34 import android.view.WindowManager; 35 36 import com.android.internal.annotations.GuardedBy; 37 import com.android.internal.messages.nano.SystemMessageProto; 38 import com.android.systemui.R; 39 import com.android.systemui.dagger.qualifiers.Background; 40 import com.android.systemui.statusbar.phone.SystemUIDialog; 41 import com.android.systemui.util.NotificationChannels; 42 import com.android.systemui.util.concurrency.DelayableExecutor; 43 44 import dagger.assisted.Assisted; 45 import dagger.assisted.AssistedFactory; 46 import dagger.assisted.AssistedInject; 47 48 /** 49 * A class that implements the four Computed Sound Dose-related warnings defined in {@link AudioManager}: 50 * <ul> 51 * <li>{@link AudioManager#CSD_WARNING_DOSE_REACHED_1X}</li> 52 * <li>{@link AudioManager#CSD_WARNING_DOSE_REPEATED_5X}</li> 53 * <li>{@link AudioManager#CSD_WARNING_MOMENTARY_EXPOSURE}</li> 54 * </ul> 55 * Rather than basing volume safety messages on a fixed volume index, the CSD feature derives its 56 * warnings from the computation of the "sound dose". The dose computation is based on a 57 * frequency-dependent analysis of the audio signal which estimates how loud and potentially harmful 58 * the signal content is. This is combined with the volume attenuation/amplification applied to it 59 * and integrated over time to derive the dose exposure over a 7 day rolling window. 60 * <p>The UI behaviors implemented in this class are defined in IEC 62368 in "Safeguards against 61 * acoustic energy sources". The events that trigger those warnings originate in SoundDoseHelper 62 * which runs in the "audio" system_server service (see 63 * frameworks/base/services/core/java/com/android/server/audio/AudioService.java for the 64 * communication between the audio framework and the volume controller, and 65 * frameworks/base/services/core/java/com/android/server/audio/SoundDoseHelper.java for the 66 * communication between the native audio framework that implements the dose computation and the 67 * audio service. 68 */ 69 public class CsdWarningDialog extends SystemUIDialog 70 implements DialogInterface.OnDismissListener, DialogInterface.OnClickListener { 71 72 private static final String TAG = Util.logTag(CsdWarningDialog.class); 73 74 private static final int KEY_CONFIRM_ALLOWED_AFTER_MS = 1000; // milliseconds 75 // time after which action is taken when the user hasn't ack'd or dismissed the dialog 76 public static final int NO_ACTION_TIMEOUT_MS = 5000; 77 78 private final Context mContext; 79 private final AudioManager mAudioManager; 80 private final @AudioManager.CsdWarning int mCsdWarning; 81 private final Object mTimerLock = new Object(); 82 83 /** 84 * Timer to keep track of how long the user has before an action (here volume reduction) is 85 * taken on their behalf. 86 */ 87 @GuardedBy("mTimerLock") 88 private Runnable mNoUserActionRunnable; 89 private Runnable mCancelScheduledNoUserActionRunnable = null; 90 91 private final DelayableExecutor mDelayableExecutor; 92 private NotificationManager mNotificationManager; 93 private Runnable mOnCleanup; 94 95 private long mShowTime; 96 97 /** 98 * To inject dependencies and allow for easier testing 99 */ 100 @AssistedFactory 101 public interface Factory { 102 /** 103 * Create a dialog object 104 */ create(int csdWarning, Runnable onCleanup)105 CsdWarningDialog create(int csdWarning, Runnable onCleanup); 106 } 107 108 @AssistedInject CsdWarningDialog(@ssisted @udioManager.CsdWarning int csdWarning, Context context, AudioManager audioManager, NotificationManager notificationManager, @Background DelayableExecutor delayableExecutor, @Assisted Runnable onCleanup)109 public CsdWarningDialog(@Assisted @AudioManager.CsdWarning int csdWarning, Context context, 110 AudioManager audioManager, NotificationManager notificationManager, 111 @Background DelayableExecutor delayableExecutor, @Assisted Runnable onCleanup) { 112 super(context); 113 mCsdWarning = csdWarning; 114 mContext = context; 115 mAudioManager = audioManager; 116 mNotificationManager = notificationManager; 117 mOnCleanup = onCleanup; 118 119 mDelayableExecutor = delayableExecutor; 120 121 getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ERROR); 122 setShowForAllUsers(true); 123 setMessage(mContext.getString(getStringForWarning(csdWarning))); 124 setButton(DialogInterface.BUTTON_POSITIVE, 125 mContext.getString(R.string.csd_button_keep_listening), this); 126 setButton(DialogInterface.BUTTON_NEGATIVE, 127 mContext.getString(R.string.csd_button_lower_volume), this); 128 setOnDismissListener(this); 129 130 final IntentFilter filter = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); 131 context.registerReceiver(mReceiver, filter, 132 Context.RECEIVER_EXPORTED_UNAUDITED); 133 134 if (csdWarning == AudioManager.CSD_WARNING_DOSE_REACHED_1X) { 135 mNoUserActionRunnable = () -> { 136 if (mCsdWarning == AudioManager.CSD_WARNING_DOSE_REACHED_1X) { 137 // unlike on the 5x dose repeat, level is only reduced to RS1 when the warning 138 // is not acknowledged quickly enough 139 mAudioManager.lowerVolumeToRs1(); 140 sendNotification(/*for5XCsd=*/false); 141 } 142 }; 143 } else { 144 mNoUserActionRunnable = null; 145 } 146 } 147 cleanUp()148 private void cleanUp() { 149 if (mOnCleanup != null) { 150 mOnCleanup.run(); 151 } 152 } 153 154 @Override show()155 public void show() { 156 if (mCsdWarning == AudioManager.CSD_WARNING_DOSE_REPEATED_5X) { 157 // only show a notification in case we reached 500% of dose 158 show5XNotification(); 159 return; 160 } 161 super.show(); 162 } 163 164 // NOT overriding onKeyDown as we're not allowing a dismissal on any key other than 165 // VOLUME_DOWN, and for this, we don't need to track if it's the start of a new 166 // key down -> up sequence 167 //@Override 168 //public boolean onKeyDown(int keyCode, KeyEvent event) { 169 // return super.onKeyDown(keyCode, event); 170 //} 171 172 @Override onKeyUp(int keyCode, KeyEvent event)173 public boolean onKeyUp(int keyCode, KeyEvent event) { 174 // never allow to raise volume 175 if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { 176 return true; 177 } 178 // VOLUME_DOWN will dismiss the dialog 179 if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN 180 && (System.currentTimeMillis() - mShowTime) > KEY_CONFIRM_ALLOWED_AFTER_MS) { 181 Log.i(TAG, "Confirmed CSD exposure warning via VOLUME_DOWN"); 182 dismiss(); 183 } 184 return super.onKeyUp(keyCode, event); 185 } 186 187 @Override onClick(DialogInterface dialog, int which)188 public void onClick(DialogInterface dialog, int which) { 189 if (which == DialogInterface.BUTTON_NEGATIVE) { 190 Log.d(TAG, "Lower volume pressed for CSD warning " + mCsdWarning); 191 dismiss(); 192 193 } 194 if (D.BUG) Log.d(TAG, "on click " + which); 195 } 196 197 @Override start()198 protected void start() { 199 mShowTime = System.currentTimeMillis(); 200 synchronized (mTimerLock) { 201 if (mNoUserActionRunnable != null) { 202 mCancelScheduledNoUserActionRunnable = mDelayableExecutor.executeDelayed( 203 mNoUserActionRunnable, NO_ACTION_TIMEOUT_MS); 204 } 205 } 206 } 207 208 @Override stop()209 protected void stop() { 210 synchronized (mTimerLock) { 211 if (mCancelScheduledNoUserActionRunnable != null) { 212 mCancelScheduledNoUserActionRunnable.run(); 213 } 214 } 215 } 216 217 @Override onDismiss(DialogInterface unused)218 public void onDismiss(DialogInterface unused) { 219 if (mCsdWarning == AudioManager.CSD_WARNING_DOSE_REPEATED_5X) { 220 // level is always reduced to RS1 beyond the 5x dose 221 mAudioManager.lowerVolumeToRs1(); 222 } 223 try { 224 mContext.unregisterReceiver(mReceiver); 225 } catch (IllegalArgumentException e) { 226 // Don't crash if the receiver has already been unregistered. 227 Log.e(TAG, "Error unregistering broadcast receiver", e); 228 } 229 cleanUp(); 230 } 231 232 private final BroadcastReceiver mReceiver = new BroadcastReceiver() { 233 @Override 234 public void onReceive(Context context, Intent intent) { 235 if (Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(intent.getAction())) { 236 if (D.BUG) Log.d(TAG, "Received ACTION_CLOSE_SYSTEM_DIALOGS"); 237 cancel(); 238 cleanUp(); 239 } 240 } 241 }; 242 getStringForWarning(@udioManager.CsdWarning int csdWarning)243 private @StringRes int getStringForWarning(@AudioManager.CsdWarning int csdWarning) { 244 switch (csdWarning) { 245 case AudioManager.CSD_WARNING_DOSE_REACHED_1X: 246 return com.android.internal.R.string.csd_dose_reached_warning; 247 case AudioManager.CSD_WARNING_MOMENTARY_EXPOSURE: 248 return com.android.internal.R.string.csd_momentary_exposure_warning; 249 } 250 Log.e(TAG, "Invalid CSD warning event " + csdWarning, new Exception()); 251 return com.android.internal.R.string.csd_dose_reached_warning; 252 } 253 254 /** When 5X CSD is reached we lower the volume and show a notification. **/ show5XNotification()255 private void show5XNotification() { 256 if (mCsdWarning != AudioManager.CSD_WARNING_DOSE_REPEATED_5X) { 257 Log.w(TAG, "Notification dose repeat 5x is not shown for " + mCsdWarning); 258 return; 259 } 260 261 mAudioManager.lowerVolumeToRs1(); 262 sendNotification(/*for5XCsd=*/true); 263 } 264 265 /** 266 * In case user did not respond to the dialog, they still need to know volume was lowered. 267 */ sendNotification(boolean for5XCsd)268 private void sendNotification(boolean for5XCsd) { 269 Intent intent = new Intent(Settings.ACTION_SOUND_SETTINGS); 270 PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, intent, 271 FLAG_IMMUTABLE); 272 273 String text = for5XCsd ? mContext.getString(R.string.csd_500_system_lowered_text) 274 : mContext.getString(R.string.csd_system_lowered_text); 275 String title = mContext.getString(R.string.csd_lowered_title); 276 277 Notification.Builder builder = 278 new Notification.Builder(mContext, NotificationChannels.ALERTS) 279 .setSmallIcon(R.drawable.hearing) 280 .setContentTitle(title) 281 .setContentText(text) 282 .setContentIntent(pendingIntent) 283 .setStyle(new Notification.BigTextStyle().bigText(text)) 284 .setVisibility(Notification.VISIBILITY_PUBLIC) 285 .setLocalOnly(true) 286 .setAutoCancel(true) 287 .setCategory(Notification.CATEGORY_SYSTEM); 288 289 mNotificationManager.notify(SystemMessageProto.SystemMessage.NOTE_CSD_LOWER_AUDIO, 290 builder.build()); 291 } 292 } 293