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