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