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