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