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