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