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