1 /*
2  * Copyright (C) 2019 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.server.audio;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.content.res.XmlResourceParser;
22 import android.media.AudioAttributes;
23 import android.media.AudioManager;
24 import android.media.AudioSystem;
25 import android.media.MediaPlayer;
26 import android.media.MediaPlayer.OnCompletionListener;
27 import android.media.MediaPlayer.OnErrorListener;
28 import android.media.PlayerBase;
29 import android.media.SoundPool;
30 import android.os.Environment;
31 import android.os.Handler;
32 import android.os.Looper;
33 import android.os.Message;
34 import android.util.Log;
35 import android.util.PrintWriterPrinter;
36 
37 import com.android.internal.util.XmlUtils;
38 import com.android.server.utils.EventLogger;
39 
40 import org.xmlpull.v1.XmlPullParserException;
41 
42 import java.io.File;
43 import java.io.IOException;
44 import java.io.PrintWriter;
45 import java.lang.reflect.Field;
46 import java.util.ArrayList;
47 import java.util.Arrays;
48 import java.util.HashMap;
49 import java.util.List;
50 import java.util.Map;
51 import java.util.function.Consumer;
52 
53 /**
54  * A helper class for managing sound effects loading / unloading
55  * used by AudioService. As its methods are called on the message handler thread
56  * of AudioService, the actual work is offloaded to a dedicated thread.
57  * This helps keeping AudioService responsive.
58  *
59  * @hide
60  */
61 class SoundEffectsHelper {
62     private static final String TAG = "AS.SfxHelper";
63 
64     private static final int NUM_SOUNDPOOL_CHANNELS = 4;
65 
66     /* Sound effect file names  */
67     private static final String SOUND_EFFECTS_PATH = "/media/audio/ui/";
68 
69     private static final int EFFECT_NOT_IN_SOUND_POOL = 0; // SoundPool sample IDs > 0
70 
71     private static final int MSG_LOAD_EFFECTS = 0;
72     private static final int MSG_UNLOAD_EFFECTS = 1;
73     private static final int MSG_PLAY_EFFECT = 2;
74     private static final int MSG_LOAD_EFFECTS_TIMEOUT = 3;
75 
76     interface OnEffectsLoadCompleteHandler {
run(boolean success)77         void run(boolean success);
78     }
79 
80     private final EventLogger
81             mSfxLogger = new EventLogger(
82             AudioManager.NUM_SOUND_EFFECTS + 10, "Sound Effects Loading");
83 
84     private final Context mContext;
85     // default attenuation applied to sound played with playSoundEffect()
86     private final int mSfxAttenuationDb;
87 
88     // thread for doing all work
89     private SfxWorker mSfxWorker;
90     // thread's message handler
91     private SfxHandler mSfxHandler;
92 
93     private static final class Resource {
94         final String mFileName;
95         int mSampleId;
96         boolean mLoaded;  // for effects in SoundPool
97 
Resource(String fileName)98         Resource(String fileName) {
99             mFileName = fileName;
100             mSampleId = EFFECT_NOT_IN_SOUND_POOL;
101         }
102 
unload()103         void unload() {
104             mSampleId = EFFECT_NOT_IN_SOUND_POOL;
105             mLoaded = false;
106         }
107     }
108 
109     // All the fields below are accessed by the worker thread exclusively
110     private final List<Resource> mResources = new ArrayList<Resource>();
111     private final int[] mEffects = new int[AudioManager.NUM_SOUND_EFFECTS]; // indexes in mResources
112     private SoundPool mSoundPool;
113     private SoundPoolLoader mSoundPoolLoader;
114     /** callback to provide handle to the player of the sound effects */
115     private final Consumer<PlayerBase> mPlayerAvailableCb;
116 
SoundEffectsHelper(Context context, Consumer<PlayerBase> playerAvailableCb)117     SoundEffectsHelper(Context context, Consumer<PlayerBase> playerAvailableCb) {
118         mContext = context;
119         mSfxAttenuationDb = mContext.getResources().getInteger(
120                 com.android.internal.R.integer.config_soundEffectVolumeDb);
121         mPlayerAvailableCb = playerAvailableCb;
122         startWorker();
123     }
124 
loadSoundEffects(OnEffectsLoadCompleteHandler onComplete)125     /*package*/ void loadSoundEffects(OnEffectsLoadCompleteHandler onComplete) {
126         sendMsg(MSG_LOAD_EFFECTS, 0, 0, onComplete, 0);
127     }
128 
129     /**
130      * Unloads samples from the sound pool.
131      * This method can be called to free some memory when
132      * sound effects are disabled.
133      */
unloadSoundEffects()134     /*package*/ void unloadSoundEffects() {
135         sendMsg(MSG_UNLOAD_EFFECTS, 0, 0, null, 0);
136     }
137 
playSoundEffect(int effect, int volume)138     /*package*/ void playSoundEffect(int effect, int volume) {
139         sendMsg(MSG_PLAY_EFFECT, effect, volume, null, 0);
140     }
141 
dump(PrintWriter pw, String prefix)142     /*package*/ void dump(PrintWriter pw, String prefix) {
143         if (mSfxHandler != null) {
144             pw.println(prefix + "Message handler (watch for unhandled messages):");
145             mSfxHandler.dump(new PrintWriterPrinter(pw), "  ");
146         } else {
147             pw.println(prefix + "Message handler is null");
148         }
149         pw.println(prefix + "Default attenuation (dB): " + mSfxAttenuationDb);
150         mSfxLogger.dump(pw);
151     }
152 
startWorker()153     private void startWorker() {
154         mSfxWorker = new SfxWorker();
155         mSfxWorker.start();
156         synchronized (this) {
157             while (mSfxHandler == null) {
158                 try {
159                     wait();
160                 } catch (InterruptedException e) {
161                     Log.w(TAG, "Interrupted while waiting " + mSfxWorker.getName() + " to start");
162                 }
163             }
164         }
165     }
166 
sendMsg(int msg, int arg1, int arg2, Object obj, int delayMs)167     private void sendMsg(int msg, int arg1, int arg2, Object obj, int delayMs) {
168         mSfxHandler.sendMessageDelayed(mSfxHandler.obtainMessage(msg, arg1, arg2, obj), delayMs);
169     }
170 
logEvent(String msg)171     private void logEvent(String msg) {
172         mSfxLogger.enqueue(new EventLogger.StringEvent(msg));
173     }
174 
175     // All the methods below run on the worker thread
onLoadSoundEffects(OnEffectsLoadCompleteHandler onComplete)176     private void onLoadSoundEffects(OnEffectsLoadCompleteHandler onComplete) {
177         if (mSoundPoolLoader != null) {
178             // Loading is ongoing.
179             mSoundPoolLoader.addHandler(onComplete);
180             return;
181         }
182         if (mSoundPool != null) {
183             if (onComplete != null) {
184                 onComplete.run(true /*success*/);
185             }
186             return;
187         }
188 
189         logEvent("effects loading started");
190         mSoundPool = new SoundPool.Builder()
191                 .setMaxStreams(NUM_SOUNDPOOL_CHANNELS)
192                 .setAudioAttributes(new AudioAttributes.Builder()
193                         .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
194                         .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
195                         .build())
196                 .build();
197         mPlayerAvailableCb.accept(mSoundPool);
198         loadSoundAssets();
199 
200         mSoundPoolLoader = new SoundPoolLoader();
201         mSoundPoolLoader.addHandler(new OnEffectsLoadCompleteHandler() {
202             @Override
203             public void run(boolean success) {
204                 mSoundPoolLoader = null;
205                 if (!success) {
206                     Log.w(TAG, "onLoadSoundEffects(), Error while loading samples");
207                     onUnloadSoundEffects();
208                 }
209             }
210         });
211         mSoundPoolLoader.addHandler(onComplete);
212 
213         int resourcesToLoad = 0;
214         for (Resource res : mResources) {
215             String filePath = getResourceFilePath(res);
216             int sampleId = mSoundPool.load(filePath, 0);
217             if (sampleId > 0) {
218                 res.mSampleId = sampleId;
219                 res.mLoaded = false;
220                 resourcesToLoad++;
221             } else {
222                 logEvent("effect " + filePath + " rejected by SoundPool");
223                 Log.w(TAG, "SoundPool could not load file: " + filePath);
224             }
225         }
226 
227         if (resourcesToLoad > 0) {
228             sendMsg(MSG_LOAD_EFFECTS_TIMEOUT, 0, 0, null, SOUND_EFFECTS_LOAD_TIMEOUT_MS);
229         } else {
230             logEvent("effects loading completed, no effects to load");
231             mSoundPoolLoader.onComplete(true /*success*/);
232         }
233     }
234 
onUnloadSoundEffects()235     void onUnloadSoundEffects() {
236         if (mSoundPool == null) {
237             return;
238         }
239         if (mSoundPoolLoader != null) {
240             mSoundPoolLoader.addHandler(new OnEffectsLoadCompleteHandler() {
241                 @Override
242                 public void run(boolean success) {
243                     onUnloadSoundEffects();
244                 }
245             });
246         }
247 
248         logEvent("effects unloading started");
249         for (Resource res : mResources) {
250             if (res.mSampleId != EFFECT_NOT_IN_SOUND_POOL) {
251                 mSoundPool.unload(res.mSampleId);
252                 res.unload();
253             }
254         }
255         mSoundPool.release();
256         mSoundPool = null;
257         logEvent("effects unloading completed");
258     }
259 
onPlaySoundEffect(int effect, int volume)260     void onPlaySoundEffect(int effect, int volume) {
261         float volFloat;
262         // use default if volume is not specified by caller
263         if (volume < 0) {
264             volFloat = (float) Math.pow(10, (float) mSfxAttenuationDb / 20);
265         } else {
266             volFloat = volume / 1000.0f;
267         }
268 
269         Resource res = mResources.get(mEffects[effect]);
270         if (mSoundPool != null && res.mSampleId != EFFECT_NOT_IN_SOUND_POOL && res.mLoaded) {
271             mSoundPool.play(res.mSampleId, volFloat, volFloat, 0, 0, 1.0f);
272         } else {
273             MediaPlayer mediaPlayer = new MediaPlayer();
274             try {
275                 String filePath = getResourceFilePath(res);
276                 mediaPlayer.setDataSource(filePath);
277                 mediaPlayer.setAudioStreamType(AudioSystem.STREAM_SYSTEM);
278                 mediaPlayer.prepare();
279                 mediaPlayer.setVolume(volFloat);
280                 mediaPlayer.setOnCompletionListener(new OnCompletionListener() {
281                     public void onCompletion(MediaPlayer mp) {
282                         cleanupPlayer(mp);
283                     }
284                 });
285                 mediaPlayer.setOnErrorListener(new OnErrorListener() {
286                     public boolean onError(MediaPlayer mp, int what, int extra) {
287                         cleanupPlayer(mp);
288                         return true;
289                     }
290                 });
291                 mediaPlayer.start();
292             } catch (IOException ex) {
293                 Log.w(TAG, "MediaPlayer IOException: " + ex);
294             } catch (IllegalArgumentException ex) {
295                 Log.w(TAG, "MediaPlayer IllegalArgumentException: " + ex);
296             } catch (IllegalStateException ex) {
297                 Log.w(TAG, "MediaPlayer IllegalStateException: " + ex);
298             }
299         }
300     }
301 
cleanupPlayer(MediaPlayer mp)302     private static void cleanupPlayer(MediaPlayer mp) {
303         if (mp != null) {
304             try {
305                 mp.stop();
306                 mp.release();
307             } catch (IllegalStateException ex) {
308                 Log.w(TAG, "MediaPlayer IllegalStateException: " + ex);
309             }
310         }
311     }
312 
313     private static final String TAG_AUDIO_ASSETS = "audio_assets";
314     private static final String ATTR_VERSION = "version";
315     private static final String TAG_GROUP = "group";
316     private static final String ATTR_GROUP_NAME = "name";
317     private static final String TAG_ASSET = "asset";
318     private static final String ATTR_ASSET_ID = "id";
319     private static final String ATTR_ASSET_FILE = "file";
320 
321     private static final String ASSET_FILE_VERSION = "1.0";
322     private static final String GROUP_TOUCH_SOUNDS = "touch_sounds";
323 
324     private static final int SOUND_EFFECTS_LOAD_TIMEOUT_MS = 15000;
325 
getResourceFilePath(Resource res)326     private String getResourceFilePath(Resource res) {
327         String filePath = Environment.getProductDirectory() + SOUND_EFFECTS_PATH + res.mFileName;
328         if (!new File(filePath).isFile()) {
329             filePath = Environment.getRootDirectory() + SOUND_EFFECTS_PATH + res.mFileName;
330         }
331         return filePath;
332     }
333 
loadSoundAssetDefaults()334     private void loadSoundAssetDefaults() {
335         int defaultResourceIdx = mResources.size();
336         mResources.add(new Resource("Effect_Tick.ogg"));
337         Arrays.fill(mEffects, defaultResourceIdx);
338     }
339 
340     /**
341      * Loads the sound assets information from audio_assets.xml
342      * The expected format of audio_assets.xml is:
343      * <ul>
344      *  <li> all {@code <asset>s} listed directly in {@code <audio_assets>} </li>
345      *  <li> for backwards compatibility: exactly one {@code <group>} with name
346      *  {@link #GROUP_TOUCH_SOUNDS} </li>
347      * </ul>
348      */
loadSoundAssets()349     private void loadSoundAssets() {
350         XmlResourceParser parser = null;
351 
352         // only load assets once.
353         if (!mResources.isEmpty()) {
354             return;
355         }
356 
357         loadSoundAssetDefaults();
358 
359         try {
360             parser = mContext.getResources().getXml(com.android.internal.R.xml.audio_assets);
361 
362             XmlUtils.beginDocument(parser, TAG_AUDIO_ASSETS);
363             String version = parser.getAttributeValue(null, ATTR_VERSION);
364             Map<Integer, Integer> parserCounter = new HashMap<>();
365             if (ASSET_FILE_VERSION.equals(version)) {
366                 while (true) {
367                     XmlUtils.nextElement(parser);
368                     String element = parser.getName();
369                     if (element == null) {
370                         break;
371                     }
372                     if (element.equals(TAG_GROUP)) {
373                         String name = parser.getAttributeValue(null, ATTR_GROUP_NAME);
374                         if (!GROUP_TOUCH_SOUNDS.equals(name)) {
375                             Log.w(TAG, "Unsupported group name: " + name);
376                         }
377                     } else if (element.equals(TAG_ASSET)) {
378                         String id = parser.getAttributeValue(null, ATTR_ASSET_ID);
379                         String file = parser.getAttributeValue(null, ATTR_ASSET_FILE);
380                         int fx;
381 
382                         try {
383                             Field field = AudioManager.class.getField(id);
384                             fx = field.getInt(null);
385                         } catch (Exception e) {
386                             Log.w(TAG, "Invalid sound ID: " + id);
387                             continue;
388                         }
389                         int currentParserCount = parserCounter.getOrDefault(fx, 0) + 1;
390                         parserCounter.put(fx, currentParserCount);
391                         if (currentParserCount > 1) {
392                             Log.w(TAG, "Duplicate definition for sound ID: " + id);
393                         }
394                         mEffects[fx] = findOrAddResourceByFileName(file);
395                     } else {
396                         break;
397                     }
398                 }
399 
400                 boolean navigationRepeatFxParsed = allNavigationRepeatSoundsParsed(parserCounter);
401                 boolean homeSoundParsed = parserCounter.getOrDefault(AudioManager.FX_HOME, 0) > 0;
402                 if (navigationRepeatFxParsed || homeSoundParsed) {
403                     AudioManager audioManager = mContext.getSystemService(AudioManager.class);
404                     if (audioManager != null && navigationRepeatFxParsed) {
405                         audioManager.setNavigationRepeatSoundEffectsEnabled(true);
406                     }
407                     if (audioManager != null && homeSoundParsed) {
408                         audioManager.setHomeSoundEffectEnabled(true);
409                     }
410                 }
411             }
412         } catch (Resources.NotFoundException e) {
413             Log.w(TAG, "audio assets file not found", e);
414         } catch (XmlPullParserException e) {
415             Log.w(TAG, "XML parser exception reading sound assets", e);
416         } catch (IOException e) {
417             Log.w(TAG, "I/O exception reading sound assets", e);
418         } finally {
419             if (parser != null) {
420                 parser.close();
421             }
422         }
423     }
424 
allNavigationRepeatSoundsParsed(Map<Integer, Integer> parserCounter)425     private boolean allNavigationRepeatSoundsParsed(Map<Integer, Integer> parserCounter) {
426         int numFastScrollSoundEffectsParsed =
427                 parserCounter.getOrDefault(AudioManager.FX_FOCUS_NAVIGATION_REPEAT_1, 0)
428                         + parserCounter.getOrDefault(AudioManager.FX_FOCUS_NAVIGATION_REPEAT_2, 0)
429                         + parserCounter.getOrDefault(AudioManager.FX_FOCUS_NAVIGATION_REPEAT_3, 0)
430                         + parserCounter.getOrDefault(AudioManager.FX_FOCUS_NAVIGATION_REPEAT_4, 0);
431         return numFastScrollSoundEffectsParsed == AudioManager.NUM_NAVIGATION_REPEAT_SOUND_EFFECTS;
432     }
433 
findOrAddResourceByFileName(String fileName)434     private int findOrAddResourceByFileName(String fileName) {
435         for (int i = 0; i < mResources.size(); i++) {
436             if (mResources.get(i).mFileName.equals(fileName)) {
437                 return i;
438             }
439         }
440         int result = mResources.size();
441         mResources.add(new Resource(fileName));
442         return result;
443     }
444 
findResourceBySampleId(int sampleId)445     private Resource findResourceBySampleId(int sampleId) {
446         for (Resource res : mResources) {
447             if (res.mSampleId == sampleId) {
448                 return res;
449             }
450         }
451         return null;
452     }
453 
454     private class SfxWorker extends Thread {
SfxWorker()455         SfxWorker() {
456             super("AS.SfxWorker");
457         }
458 
459         @Override
run()460         public void run() {
461             Looper.prepare();
462             synchronized (SoundEffectsHelper.this) {
463                 mSfxHandler = new SfxHandler();
464                 SoundEffectsHelper.this.notify();
465             }
466             Looper.loop();
467         }
468     }
469 
470     private class SfxHandler extends Handler {
471         @Override
handleMessage(Message msg)472         public void handleMessage(Message msg) {
473             switch (msg.what) {
474                 case MSG_LOAD_EFFECTS:
475                     onLoadSoundEffects((OnEffectsLoadCompleteHandler) msg.obj);
476                     break;
477                 case MSG_UNLOAD_EFFECTS:
478                     onUnloadSoundEffects();
479                     break;
480                 case MSG_PLAY_EFFECT:
481                     final int effect = msg.arg1, volume = msg.arg2;
482                     onLoadSoundEffects(new OnEffectsLoadCompleteHandler() {
483                         @Override
484                         public void run(boolean success) {
485                             if (success) {
486                                 onPlaySoundEffect(effect, volume);
487                             }
488                         }
489                     });
490                     break;
491                 case MSG_LOAD_EFFECTS_TIMEOUT:
492                     if (mSoundPoolLoader != null) {
493                         mSoundPoolLoader.onTimeout();
494                     }
495                     break;
496             }
497         }
498     }
499 
500     private class SoundPoolLoader implements
501             android.media.SoundPool.OnLoadCompleteListener {
502 
503         private List<OnEffectsLoadCompleteHandler> mLoadCompleteHandlers =
504                 new ArrayList<OnEffectsLoadCompleteHandler>();
505 
SoundPoolLoader()506         SoundPoolLoader() {
507             // SoundPool use the current Looper when creating its message handler.
508             // Since SoundPoolLoader is created on the SfxWorker thread, SoundPool's
509             // message handler ends up running on it (it's OK to have multiple
510             // handlers on the same Looper). Thus, onLoadComplete gets executed
511             // on the worker thread.
512             mSoundPool.setOnLoadCompleteListener(this);
513         }
514 
addHandler(OnEffectsLoadCompleteHandler handler)515         void addHandler(OnEffectsLoadCompleteHandler handler) {
516             if (handler != null) {
517                 mLoadCompleteHandlers.add(handler);
518             }
519         }
520 
521         @Override
onLoadComplete(SoundPool soundPool, int sampleId, int status)522         public void onLoadComplete(SoundPool soundPool, int sampleId, int status) {
523             if (status == 0) {
524                 int remainingToLoad = 0;
525                 for (Resource res : mResources) {
526                     if (res.mSampleId == sampleId && !res.mLoaded) {
527                         logEvent("effect " + res.mFileName + " loaded");
528                         res.mLoaded = true;
529                     }
530                     if (res.mSampleId != EFFECT_NOT_IN_SOUND_POOL && !res.mLoaded) {
531                         remainingToLoad++;
532                     }
533                 }
534                 if (remainingToLoad == 0) {
535                     onComplete(true);
536                 }
537             } else {
538                 Resource res = findResourceBySampleId(sampleId);
539                 String filePath;
540                 if (res != null) {
541                     filePath = getResourceFilePath(res);
542                 } else {
543                     filePath = "with unknown sample ID " + sampleId;
544                 }
545                 logEvent("effect " + filePath + " loading failed, status " + status);
546                 Log.w(TAG, "onLoadSoundEffects(), Error " + status + " while loading sample "
547                         + filePath);
548                 onComplete(false);
549             }
550         }
551 
onTimeout()552         void onTimeout() {
553             onComplete(false);
554         }
555 
onComplete(boolean success)556         void onComplete(boolean success) {
557             if (mSoundPool != null) {
558                 mSoundPool.setOnLoadCompleteListener(null);
559             }
560             for (OnEffectsLoadCompleteHandler handler : mLoadCompleteHandlers) {
561                 handler.run(success);
562             }
563             logEvent("effects loading " + (success ? "completed" : "failed"));
564         }
565     }
566 }
567