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