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