Add support for VibrationEffect and audio-coupled vibrations to Ringtone.

Rename playWithVolumeShaping to playRemoteRingtone, to make the application
clearer.

Bug: 272280617, 279157059
Test: presubmit
Change-Id: Ic1b753cde9afc6536d137618df54c21eec38487a
This commit is contained in:
Simon Bowden
2023-03-11 22:57:36 +00:00
parent 03123a4420
commit 108385e178
7 changed files with 710 additions and 137 deletions

View File

@@ -34,6 +34,7 @@ import android.os.vibrator.PrimitiveSegment;
import android.os.vibrator.RampSegment;
import android.os.vibrator.StepSegment;
import android.os.vibrator.VibrationEffectSegment;
import android.util.Log;
import android.util.MathUtils;
import com.android.internal.util.Preconditions;
@@ -52,6 +53,7 @@ import java.util.Objects;
* <p>These effects may be any number of things, from single shot vibrations to complex waveforms.
*/
public abstract class VibrationEffect implements Parcelable {
private static final String TAG = "VibrationEffect";
// Stevens' coefficient to scale the perceived vibration intensity.
private static final float SCALE_GAMMA = 0.65f;
// If a vibration is playing for longer than 1s, it's probably not haptic feedback
@@ -394,26 +396,32 @@ public abstract class VibrationEffect implements Parcelable {
return null;
}
final ContentResolver cr = context.getContentResolver();
Uri uncanonicalUri = cr.uncanonicalize(uri);
if (uncanonicalUri == null) {
// If we already had an uncanonical URI, it's possible we'll get null back here. In
// this case, just use the URI as passed in since it wasn't canonicalized in the first
// place.
uncanonicalUri = uri;
}
try {
final ContentResolver cr = context.getContentResolver();
Uri uncanonicalUri = cr.uncanonicalize(uri);
if (uncanonicalUri == null) {
// If we already had an uncanonical URI, it's possible we'll get null back here. In
// this case, just use the URI as passed in since it wasn't canonicalized in the
// first place.
uncanonicalUri = uri;
}
for (int i = 0; i < uris.length && i < RINGTONES.length; i++) {
if (uris[i] == null) {
continue;
}
Uri mappedUri = cr.uncanonicalize(Uri.parse(uris[i]));
if (mappedUri == null) {
continue;
}
if (mappedUri.equals(uncanonicalUri)) {
return get(RINGTONES[i]);
for (int i = 0; i < uris.length && i < RINGTONES.length; i++) {
if (uris[i] == null) {
continue;
}
Uri mappedUri = cr.uncanonicalize(Uri.parse(uris[i]));
if (mappedUri == null) {
continue;
}
if (mappedUri.equals(uncanonicalUri)) {
return get(RINGTONES[i]);
}
}
} catch (Exception e) {
// Don't give unexpected exceptions to callers if the Uri's ContentProvider is
// misbehaving - it's very unlikely to be mapped in that case anyway.
Log.e(TAG, "Exception getting default vibration for Uri " + uri, e);
}
return null;
}

View File

@@ -21,6 +21,7 @@ import android.media.VolumeShaper;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.UserHandle;
import android.os.VibrationEffect;
/**
* @hide
@@ -29,7 +30,8 @@ interface IRingtonePlayer {
/** Used for Ringtone.java playback */
@UnsupportedAppUsage
oneway void play(IBinder token, in Uri uri, in AudioAttributes aa, float volume, boolean looping);
oneway void playWithVolumeShaping(IBinder token, in Uri uri, in AudioAttributes aa,
oneway void playRemoteRingtone(IBinder token, in Uri uri, in AudioAttributes aa,
boolean useExactAudioAttributes, int enabledMedia, in @nullable VibrationEffect ve,
float volume, boolean looping, boolean hapticGeneratorEnabled,
in @nullable VolumeShaper.Configuration volumeShaperConfig);
oneway void stop(IBinder token);

View File

@@ -23,6 +23,9 @@ import android.content.res.AssetFileDescriptor;
import android.media.audiofx.HapticGenerator;
import android.net.Uri;
import android.os.Trace;
import android.os.VibrationAttributes;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.util.Log;
import java.io.IOException;
@@ -36,21 +39,27 @@ import java.util.Objects;
public class LocalRingtonePlayer
implements Ringtone.RingtonePlayer, MediaPlayer.OnCompletionListener {
private static final String TAG = "LocalRingtonePlayer";
private static final int VIBRATION_LOOP_DELAY_MS = 200;
// keep references on active Ringtones until stopped or completion listener called.
private static final ArrayList<LocalRingtonePlayer> sActiveRingtones = new ArrayList<>();
private static final ArrayList<LocalRingtonePlayer> sActiveMediaPlayers = new ArrayList<>();
private final MediaPlayer mMediaPlayer;
private final AudioAttributes mAudioAttributes;
private final VibrationAttributes mVibrationAttributes;
private final Ringtone.Injectables mInjectables;
private final AudioManager mAudioManager;
private final VolumeShaper mVolumeShaper;
private final Vibrator mVibrator;
private final VibrationEffect mVibrationEffect;
private HapticGenerator mHapticGenerator;
private boolean mStartedVibration;
private LocalRingtonePlayer(@NonNull MediaPlayer mediaPlayer,
@NonNull AudioAttributes audioAttributes, @NonNull Ringtone.Injectables injectables,
@NonNull AudioManager audioManager, @Nullable HapticGenerator hapticGenerator,
@Nullable VolumeShaper volumeShaper) {
@Nullable VolumeShaper volumeShaper, @NonNull Vibrator vibrator,
@Nullable VibrationEffect vibrationEffect) {
Objects.requireNonNull(mediaPlayer);
Objects.requireNonNull(audioAttributes);
Objects.requireNonNull(injectables);
@@ -60,7 +69,11 @@ public class LocalRingtonePlayer
mInjectables = injectables;
mAudioManager = audioManager;
mVolumeShaper = volumeShaper;
mVibrator = vibrator;
mVibrationEffect = vibrationEffect;
mHapticGenerator = hapticGenerator;
mVibrationAttributes = (mVibrationEffect == null) ? null :
new VibrationAttributes.Builder(audioAttributes).build();
}
/**
@@ -69,8 +82,9 @@ public class LocalRingtonePlayer
*/
@Nullable
static LocalRingtonePlayer create(@NonNull Context context,
@NonNull AudioManager audioManager, @NonNull Uri soundUri,
@NonNull AudioManager audioManager, @NonNull Vibrator vibrator, @NonNull Uri soundUri,
@NonNull AudioAttributes audioAttributes,
@Nullable VibrationEffect vibrationEffect,
@NonNull Ringtone.Injectables injectables,
@Nullable VolumeShaper.Configuration volumeShaperConfig,
@Nullable AudioDeviceInfo preferredDevice, boolean initialHapticGeneratorEnabled,
@@ -89,15 +103,26 @@ public class LocalRingtonePlayer
mediaPlayer.setVolume(initialVolume);
if (initialHapticGeneratorEnabled) {
hapticGenerator = injectables.createHapticGenerator(mediaPlayer);
hapticGenerator.setEnabled(true);
if (hapticGenerator != null) {
// In practise, this should always be non-null because the initial value is
// not true unless it's available.
hapticGenerator.setEnabled(true);
vibrationEffect = null; // Don't play the VibrationEffect.
}
}
VolumeShaper volumeShaper = null;
if (volumeShaperConfig != null) {
volumeShaper = mediaPlayer.createVolumeShaper(volumeShaperConfig);
}
mediaPlayer.prepare();
if (vibrationEffect != null && !audioAttributes.areHapticChannelsMuted()) {
if (injectables.hasHapticChannels(mediaPlayer)) {
// Don't play the Vibration effect if the URI has haptic channels.
vibrationEffect = null;
}
}
return new LocalRingtonePlayer(mediaPlayer, audioAttributes, injectables, audioManager,
hapticGenerator, volumeShaper);
hapticGenerator, volumeShaper, vibrator, vibrationEffect);
} catch (SecurityException | IOException e) {
if (hapticGenerator != null) {
hapticGenerator.release();
@@ -116,8 +141,10 @@ public class LocalRingtonePlayer
*/
@Nullable
static LocalRingtonePlayer createForFallback(
@NonNull AudioManager audioManager, @NonNull AssetFileDescriptor afd,
@NonNull AudioManager audioManager, @NonNull Vibrator vibrator,
@NonNull AssetFileDescriptor afd,
@NonNull AudioAttributes audioAttributes,
@Nullable VibrationEffect vibrationEffect,
@NonNull Ringtone.Injectables injectables,
@Nullable VolumeShaper.Configuration volumeShaperConfig,
@Nullable AudioDeviceInfo preferredDevice,
@@ -146,10 +173,17 @@ public class LocalRingtonePlayer
volumeShaper = mediaPlayer.createVolumeShaper(volumeShaperConfig);
}
mediaPlayer.prepare();
return new LocalRingtonePlayer(mediaPlayer, audioAttributes, injectables, audioManager,
/* hapticGenerator= */ null, volumeShaper);
if (vibrationEffect != null && !audioAttributes.areHapticChannelsMuted()) {
if (injectables.hasHapticChannels(mediaPlayer)) {
// Don't play the Vibration effect if the URI has haptic channels.
vibrationEffect = null;
}
}
return new LocalRingtonePlayer(mediaPlayer, audioAttributes, injectables, audioManager,
/* hapticGenerator= */ null, volumeShaper, vibrator, vibrationEffect);
} catch (SecurityException | IOException e) {
Log.e(TAG, "Failed to open fallback ringtone");
// TODO: vibration-effect-only / no-sound LocalRingtonePlayer.
mediaPlayer.release();
return null;
} finally {
@@ -163,10 +197,14 @@ public class LocalRingtonePlayer
// (typically because ringer mode is vibrate).
if (mAudioManager.getStreamVolume(AudioAttributes.toLegacyStreamType(mAudioAttributes))
== 0 && (mAudioAttributes.areHapticChannelsMuted() || !hasHapticChannels())) {
maybeStartVibration();
return true; // Successfully played while muted.
}
synchronized (sActiveRingtones) {
sActiveRingtones.add(this);
synchronized (sActiveMediaPlayers) {
// We keep-alive when a mediaplayer is active, since its finalizer would stop the
// ringtone. This isn't necessary for vibrations in the vibrator service
// (i.e. maybeStartVibration in the muted case, above).
sActiveMediaPlayers.add(this);
}
mMediaPlayer.setOnCompletionListener(this);
@@ -174,9 +212,41 @@ public class LocalRingtonePlayer
if (mVolumeShaper != null) {
mVolumeShaper.apply(VolumeShaper.Operation.PLAY);
}
maybeStartVibration();
return true;
}
private void maybeStartVibration() {
if (mVibrationEffect != null && !mStartedVibration) {
boolean isLooping = mMediaPlayer.isLooping();
try {
// Adjust the vibration effect to loop.
VibrationEffect loopAdjustedEffect = mVibrationEffect.applyRepeatingIndefinitely(
isLooping, VIBRATION_LOOP_DELAY_MS);
mVibrator.vibrate(loopAdjustedEffect, mVibrationAttributes);
mStartedVibration = true;
} catch (Exception e) {
// Catch exceptions widely, because we don't want to "leak" looping sounds or
// vibrations if something goes wrong.
Log.e(TAG, "Problem starting " + (isLooping ? "looping " : "") + "vibration "
+ "for ringtone: " + mVibrationEffect, e);
}
}
}
private void stopVibration() {
if (mVibrationEffect != null && mStartedVibration) {
try {
mVibrator.cancel(mVibrationAttributes.getUsage());
mStartedVibration = false;
} catch (Exception e) {
// Catch exceptions widely, because we don't want to "leak" looping sounds or
// vibrations if something goes wrong.
Log.e(TAG, "Problem stopping vibration for ringtone", e);
}
}
}
@Override
public boolean isPlaying() {
return mMediaPlayer.isPlaying();
@@ -184,15 +254,20 @@ public class LocalRingtonePlayer
@Override
public void stopAndRelease() {
synchronized (sActiveRingtones) {
sActiveRingtones.remove(this);
synchronized (sActiveMediaPlayers) {
sActiveMediaPlayers.remove(this);
}
if (mHapticGenerator != null) {
mHapticGenerator.release();
try {
mMediaPlayer.stop();
} finally {
stopVibration(); // Won't throw: catches exceptions.
if (mHapticGenerator != null) {
mHapticGenerator.release();
}
mMediaPlayer.setOnCompletionListener(null);
mMediaPlayer.reset();
mMediaPlayer.release();
}
mMediaPlayer.setOnCompletionListener(null);
mMediaPlayer.reset();
mMediaPlayer.release();
}
@Override
@@ -202,11 +277,29 @@ public class LocalRingtonePlayer
@Override
public void setLooping(boolean looping) {
boolean wasLooping = mMediaPlayer.isLooping();
if (wasLooping == looping) {
return;
}
mMediaPlayer.setLooping(looping);
// If transitioning from looping to not-looping during play, then cancel the vibration.
if (mVibrationEffect != null && mMediaPlayer.isPlaying()) {
if (wasLooping) {
stopVibration();
} else {
// Won't restart the vibration to be looping if it was already started.
maybeStartVibration();
}
}
}
@Override
public void setHapticGeneratorEnabled(boolean enabled) {
if (mVibrationEffect != null) {
// Ignore haptic generator changes if a vibration effect is present. The decision to
// use one or the other happens before this object is constructed.
return;
}
if (enabled && mHapticGenerator == null) {
mHapticGenerator = mInjectables.createHapticGenerator(mMediaPlayer);
}
@@ -220,30 +313,15 @@ public class LocalRingtonePlayer
mMediaPlayer.setVolume(volume);
}
/**
* Same as AudioManager.hasHapticChannels except it assumes an already created ringtone.
* @hide
*/
@Override
public boolean hasHapticChannels() {
// FIXME: support remote player, or internalize haptic channels support and remove entirely.
try {
Trace.beginSection("LocalRingtonePlayer.hasHapticChannels");
for (MediaPlayer.TrackInfo trackInfo : mMediaPlayer.getTrackInfo()) {
if (trackInfo.hasHapticChannels()) {
return true;
}
}
} finally {
Trace.endSection();
}
return false;
return mInjectables.hasHapticChannels(mMediaPlayer);
}
@Override
public void onCompletion(MediaPlayer mp) {
synchronized (sActiveRingtones) {
sActiveRingtones.remove(this);
synchronized (sActiveMediaPlayers) {
sActiveMediaPlayers.remove(this);
}
mp.setOnCompletionListener(null); // Help the Java GC: break the refcount cycle.
}

View File

@@ -16,6 +16,7 @@
package android.media;
import android.Manifest;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
@@ -23,6 +24,7 @@ import android.compat.annotation.UnsupportedAppUsage;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.AssetFileDescriptor;
import android.content.res.Resources.NotFoundException;
import android.database.Cursor;
@@ -32,6 +34,8 @@ import android.os.Binder;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.Trace;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.provider.MediaStore;
import android.provider.MediaStore.MediaColumns;
import android.provider.Settings;
@@ -62,6 +66,7 @@ public class Ringtone {
public static final int MEDIA_SOUND = 1;
/**
* The ringtone should only play vibration. Any sound is managed externally.
* Requires the {@link android.Manifest.permission#VIBRATE} permission.
* @hide
*/
public static final int MEDIA_VIBRATION = 1 << 1;
@@ -96,6 +101,7 @@ public class Ringtone {
+ MediaColumns.MIME_TYPE + " IN ('application/ogg', 'application/x-flac')";
private final Context mContext;
private final Vibrator mVibrator;
private final AudioManager mAudioManager;
private VolumeShaper.Configuration mVolumeShaperConfig;
@@ -114,27 +120,42 @@ public class Ringtone {
private String mTitle;
private AudioAttributes mAudioAttributes;
private boolean mUseExactAudioAttributes;
private boolean mPreferBuiltinDevice;
private RingtonePlayer mActivePlayer;
// playback properties, use synchronized with mPlaybackSettingsLock
private boolean mIsLooping = false;
private boolean mIsLooping;
private float mVolume;
private boolean mHapticGeneratorEnabled = false;
private boolean mHapticGeneratorEnabled;
private final Object mPlaybackSettingsLock = new Object();
private final VibrationEffect mVibrationEffect;
private Ringtone(Builder builder) {
private Ringtone(Builder builder, @Ringtone.RingtoneMedia int effectiveEnabledMedia,
@NonNull AudioAttributes effectiveAudioAttributes,
@Nullable VibrationEffect effectiveVibrationEffect,
boolean effectiveHapticGeneratorEnabled) {
// Context
mContext = builder.mContext;
mEnabledMedia = builder.mEnabledMedia;
mUri = builder.mUri;
mAudioAttributes = builder.mAudioAttributes;
mInjectables = builder.mInjectables;
mPreferBuiltinDevice = builder.mPreferBuiltinDevice;
//mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
mAudioManager = mContext.getSystemService(AudioManager.class);
mRemoteRingtoneService = builder.mAllowRemote ? mAudioManager.getRingtonePlayer() : null;
mVibrator = mContext.getSystemService(Vibrator.class);
// Local-only (not propagated to remote).
mPreferBuiltinDevice = builder.mPreferBuiltinDevice; // System-only
mAllowRemote = (mRemoteRingtoneService != null); // Always false for remote.
// Properties potentially propagated to remote player.
mEnabledMedia = effectiveEnabledMedia;
mUri = builder.mUri;
mVolumeShaperConfig = builder.mVolumeShaperConfig;
mVolume = builder.mInitialSoundVolume;
mIsLooping = builder.mLooping;
mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
mRemoteRingtoneService = builder.mAllowRemote ? mAudioManager.getRingtonePlayer() : null;
mAllowRemote = (mRemoteRingtoneService != null);
mVibrationEffect = effectiveVibrationEffect;
mAudioAttributes = effectiveAudioAttributes;
mUseExactAudioAttributes = builder.mUseExactAudioAttributes;
mHapticGeneratorEnabled = effectiveHapticGeneratorEnabled;
}
/** @hide */
@@ -193,6 +214,16 @@ public class Ringtone {
}
}
/**
* Returns the vibration effect that this ringtone was created with, if vibration is enabled.
* Otherwise, returns null.
* @hide
*/
@Nullable
public VibrationEffect getVibrationEffect() {
return mVibrationEffect;
}
/** @hide */
@VisibleForTesting
public boolean getPreferBuiltinDevice() {
@@ -258,9 +289,9 @@ public class Ringtone {
AudioDeviceInfo preferredDevice =
mPreferBuiltinDevice ? getBuiltinDevice(mAudioManager) : null;
if (mUri != null) {
mActivePlayer = LocalRingtonePlayer.create(mContext, mAudioManager, mUri,
mAudioAttributes, mInjectables, mVolumeShaperConfig, preferredDevice,
mHapticGeneratorEnabled, mIsLooping, mVolume);
mActivePlayer = LocalRingtonePlayer.create(mContext, mAudioManager, mVibrator, mUri,
mAudioAttributes, mVibrationEffect, mInjectables, mVolumeShaperConfig,
preferredDevice, mHapticGeneratorEnabled, mIsLooping, mVolume);
} else {
// Using the remote player won't help play a null Uri. Revert straight to fallback.
mActivePlayer = createFallbackRingtonePlayer();
@@ -269,9 +300,10 @@ public class Ringtone {
if (mActivePlayer == null && mAllowRemote) {
mActivePlayer = new RemoteRingtonePlayer(mRemoteRingtoneService, mUri,
mAudioAttributes,
mAudioAttributes, mUseExactAudioAttributes, mEnabledMedia, mVibrationEffect,
mVolumeShaperConfig, mHapticGeneratorEnabled, mIsLooping, mVolume);
}
return mActivePlayer != null;
} finally {
Trace.endSection();
@@ -296,9 +328,9 @@ public class Ringtone {
AudioDeviceInfo preferredDevice =
mPreferBuiltinDevice ? getBuiltinDevice(mAudioManager) : null;
return LocalRingtonePlayer.createForFallback(mAudioManager, afd,
mAudioAttributes, mInjectables, mVolumeShaperConfig, preferredDevice,
mIsLooping, mVolume);
return LocalRingtonePlayer.createForFallback(mAudioManager, mVibrator, afd,
mAudioAttributes, mVibrationEffect, mInjectables, mVolumeShaperConfig,
preferredDevice, mIsLooping, mVolume);
} catch (NotFoundException nfe) {
Log.e(TAG, "Fallback ringtone does not exist");
return null;
@@ -575,7 +607,9 @@ public class Ringtone {
/**
* Build a {@link Ringtone} to easily play sounds for ringtones, alarms and notifications.
*
* TODO: when un-hide, deprecate Ringtone: setAudioAttributes.
* TODO: when un-hide, deprecate Ringtone: setAudioAttributes, setLooping,
* setHapticGeneratorEnabled (no-effect if MEDIA_VIBRATION),
* static RingtoneManager.getRingtone.
* @hide
*/
public static final class Builder {
@@ -583,13 +617,16 @@ public class Ringtone {
private final int mEnabledMedia;
private Uri mUri;
private final AudioAttributes mAudioAttributes;
private boolean mUseExactAudioAttributes = false;
// Not a static default since it doesn't really need to be in memory forever.
private Injectables mInjectables = new Injectables();
private VolumeShaper.Configuration mVolumeShaperConfig;
private boolean mPreferBuiltinDevice = false;
private boolean mAllowRemote = true;
private boolean mHapticGeneratorEnabled = false;
private float mInitialSoundVolume = 1.0f;
private boolean mLooping = false;
private VibrationEffect mVibrationEffect;
/**
* Constructs a builder to play the given media types from the mediaUri. If the mediaUri
@@ -599,15 +636,14 @@ public class Ringtone {
* silent, then the {@link #build} may return null.
*
* @param context The context for playing the ringtone.
* @param enabledMedia Which media to play. Media not included is implicitly muted.
* @param audioAttributes The attributes to use for playback, which affects the volumes and
* @param enabledMedia Which media to play. Media not included is implicitly muted. Device
* settings such as volume and vibrate-only may also affect which
* media is played.
* @param audioAttributes The attributes to use for playback, which affects the volumes and
* settings that are applied.
*/
public Builder(@NonNull Context context, @RingtoneMedia int enabledMedia,
@NonNull AudioAttributes audioAttributes) {
if (enabledMedia != MEDIA_SOUND) {
throw new UnsupportedOperationException("Other media types not supported yet");
}
mContext = context;
mEnabledMedia = enabledMedia;
mAudioAttributes = audioAttributes;
@@ -636,6 +672,27 @@ public class Ringtone {
return this;
}
/**
* Sets the VibrationEffect to use if vibration is enabled on this ringtone. The caller
* should use {@link android.os.Vibrator#areVibrationFeaturesSupported} to ensure
* that the effect is usable on this device, otherwise system defaults will be used.
*
* <p>Vibration will only happen if the Builder was created with media type
* {@link Ringtone#MEDIA_VIBRATION} or {@link Ringtone#MEDIA_SOUND_AND_VIBRATION}, and
* the application has the {@link android.Manifest.permission#VIBRATE} permission.
*
* <p>If the Ringtone is looping when it is played, then the VibrationEffect will be
* modified to loop. Similarly, if the ringtone is not looping, a repeating
* VibrationEffect will be modified to be non-repeating when the ringtone is played. Calls
* to {@link Ringtone#setLooping} after the ringtone has started playing will stop a looping
* vibration, but has no effect otherwise: specifically it will not restart vibration.
*/
@NonNull
public Builder setVibrationEffect(@NonNull VibrationEffect effect) {
mVibrationEffect = effect;
return this;
}
/**
* Sets whether the resulting ringtone should loop until {@link Ringtone#stop()} is called,
* or just play once.
@@ -657,6 +714,17 @@ public class Ringtone {
return this;
}
/**
* Whether to enable or disable the haptic generator.
* @hide
*/
@NonNull
public Builder setEnableHapticGenerator(boolean enabled) {
// Note that this property is mutable (but deprecated) on the Ringtone class itself.
mHapticGeneratorEnabled = enabled;
return this;
}
/**
* Sets the initial sound volume for the ringtone.
*/
@@ -667,12 +735,36 @@ public class Ringtone {
}
/**
* Sets the preferred device of the ringtone playback to the built-in device.
* Sets the preferred device of the ringtone playback to the built-in device. This is
* only for use by the system server with known-good Uris.
* @hide
*/
@NonNull
public Builder setPreferBuiltinDevice() {
mPreferBuiltinDevice = true;
mAllowRemote = false; // Already in system.
return this;
}
/**
* Indicates that {@link AudioAttributes#areHapticChannelsMuted()} on the builder's
* AudioAttributes should not be overridden. This is used to enable legacy behavior of
* calling {@link Ringtone#setAudioAttributes} on an already-created ringtone, and can in
* turn cause vibration during a "sound-only" session or can suppress audio-coupled
* haptics that would usually take priority (therefore potentially falling back to
* the VibrationEffect or system defaults).
*
* <p>Without this setting, the haptic channels will be automatically muted or not by the
* Ringtone according to whether vibration is enabled or not.
*
* <p>This is for internal-use only. New applications should configure the vibration
* behavior explicitly with the (TODO: future RingtoneSetting.setVibrationSource).
* Handling haptic channels outside Ringtone leads to extra loads of the sound uri.
* @hide
*/
@NonNull
public Builder setUseExactAudioAttributes(boolean useExactAttrs) {
mUseExactAudioAttributes = useExactAttrs;
return this;
}
@@ -687,14 +779,77 @@ public class Ringtone {
return this;
}
private boolean isVibrationEnabledAndAvailable() {
if ((mEnabledMedia & MEDIA_VIBRATION) == 0) {
return false;
}
Vibrator vibrator = mContext.getSystemService(Vibrator.class);
if (!vibrator.hasVibrator()) {
return false;
}
if (mContext.checkSelfPermission(Manifest.permission.VIBRATE)
!= PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Ringtone requests vibration enabled, but no VIBRATE permission");
return false;
}
return true;
}
/**
* Returns the built Ringtone, or null if there was a problem loading the Uri and there
* are no fallback options available.
*/
@Nullable
public Ringtone build() {
@Ringtone.RingtoneMedia int effectiveEnabledMedia = mEnabledMedia;
VibrationEffect effectiveVibrationEffect = mVibrationEffect;
// Normalize media to that supported on this SDK level.
if (effectiveEnabledMedia != (effectiveEnabledMedia & MEDIA_ALL)) {
Log.e(TAG, "Unsupported media type: " + effectiveEnabledMedia);
effectiveEnabledMedia = effectiveEnabledMedia & MEDIA_ALL;
}
final boolean effectiveHapticGenerator;
final boolean hapticChannelsSupported;
AudioAttributes effectiveAudioAttributes = mAudioAttributes;
final boolean hapticChannelsMuted = mAudioAttributes.areHapticChannelsMuted();
if (!isVibrationEnabledAndAvailable()) {
// Vibration isn't active: turn off everything that might cause extra work.
effectiveEnabledMedia &= ~MEDIA_VIBRATION;
effectiveHapticGenerator = false;
effectiveVibrationEffect = null;
if (!mUseExactAudioAttributes && !hapticChannelsMuted) {
effectiveAudioAttributes = new AudioAttributes.Builder(effectiveAudioAttributes)
.setHapticChannelsMuted(true)
.build();
}
} else {
// Vibration is active.
effectiveHapticGenerator =
mHapticGeneratorEnabled && mInjectables.isHapticGeneratorAvailable();
hapticChannelsSupported = mInjectables.isHapticPlaybackSupported();
// Haptic channels are preferred if they are available, and not explicitly muted.
// We won't know if haptic channels are available until loading the media player,
// and since the media player needs to be reset to change audio attributes, then
// we proactively enable the channels - it won't matter if they aren't present.
if (!mUseExactAudioAttributes) {
boolean shouldBeMuted = effectiveHapticGenerator || !hapticChannelsSupported;
if (shouldBeMuted != hapticChannelsMuted) {
effectiveAudioAttributes =
new AudioAttributes.Builder(effectiveAudioAttributes)
.setHapticChannelsMuted(shouldBeMuted)
.build();
}
}
// If no contextual vibration, then try loading the default one for the URI.
if (mVibrationEffect == null && mUri != null) {
effectiveVibrationEffect = VibrationEffect.get(mUri, mContext);
}
}
try {
Ringtone ringtone = new Ringtone(this);
Ringtone ringtone = new Ringtone(this, effectiveEnabledMedia,
effectiveAudioAttributes, effectiveVibrationEffect,
effectiveHapticGenerator);
if (ringtone.reinitializeActivePlayer()) {
return ringtone;
} else {
@@ -706,7 +861,7 @@ public class Ringtone {
// RingtoneManager.getRingtone and hides errors like DocumentsProvider throwing
// IllegalArgumentException instead of FileNotFoundException, and also robolectric
// failures when ShadowMediaPlayer wasn't pre-informed of the ringtone.
Log.e(TAG, "Failed to open ringtone " + mUri + ": " + ex);
Log.e(TAG, "Failed while opening ringtone " + mUri, ex);
return null;
}
}
@@ -744,21 +899,29 @@ public class Ringtone {
private final IBinder mRemoteToken = new Binder();
private final IRingtonePlayer mRemoteRingtoneService;
private final Uri mCanonicalUri;
private final int mEnabledMedia;
private final VibrationEffect mVibrationEffect;
private final VolumeShaper.Configuration mVolumeShaperConfig;
private final AudioAttributes mAudioAttributes;
private final boolean mUseExactAudioAttributes;
private boolean mIsLooping;
private float mVolume;
private boolean mIsHapticGeneratorEnabled;
private boolean mHapticGeneratorEnabled;
RemoteRingtonePlayer(@NonNull IRingtonePlayer remoteRingtoneService,
@NonNull Uri uri, @NonNull AudioAttributes audioAttributes,
boolean useExactAudioAttributes,
@RingtoneMedia int enabledMedia, @Nullable VibrationEffect vibrationEffect,
@Nullable VolumeShaper.Configuration volumeShaperConfig,
boolean isHapticGeneratorEnabled, boolean initialIsLooping, float initialVolume) {
boolean hapticGeneratorEnabled, boolean initialIsLooping, float initialVolume) {
mRemoteRingtoneService = remoteRingtoneService;
mCanonicalUri = (uri == null) ? null : uri.getCanonicalUri();
mAudioAttributes = audioAttributes;
mUseExactAudioAttributes = useExactAudioAttributes;
mEnabledMedia = enabledMedia;
mVibrationEffect = vibrationEffect;
mVolumeShaperConfig = volumeShaperConfig;
mIsHapticGeneratorEnabled = isHapticGeneratorEnabled;
mHapticGeneratorEnabled = hapticGeneratorEnabled;
mIsLooping = initialIsLooping;
mVolume = initialVolume;
}
@@ -766,9 +929,9 @@ public class Ringtone {
@Override
public boolean play() {
try {
mRemoteRingtoneService.playWithVolumeShaping(mRemoteToken, mCanonicalUri,
mAudioAttributes, mVolume, mIsLooping, mIsHapticGeneratorEnabled,
mVolumeShaperConfig);
mRemoteRingtoneService.playRemoteRingtone(mRemoteToken, mCanonicalUri,
mAudioAttributes, mUseExactAudioAttributes, mEnabledMedia, mVibrationEffect,
mVolume, mIsLooping, mHapticGeneratorEnabled, mVolumeShaperConfig);
return true;
} catch (RemoteException e) {
Log.w(TAG, "Problem playing ringtone: " + e);
@@ -812,7 +975,7 @@ public class Ringtone {
@Override
public void setHapticGeneratorEnabled(boolean enabled) {
mIsHapticGeneratorEnabled = enabled;
mHapticGeneratorEnabled = enabled;
try {
mRemoteRingtoneService.setHapticGeneratorEnabled(mRemoteToken, enabled);
} catch (RemoteException e) {
@@ -863,5 +1026,30 @@ public class Ringtone {
public HapticGenerator createHapticGenerator(@NonNull MediaPlayer mediaPlayer) {
return HapticGenerator.create(mediaPlayer.getAudioSessionId());
}
/** Returns the result of {@link AudioManager#isHapticPlaybackSupported()}. */
public boolean isHapticPlaybackSupported() {
return AudioManager.isHapticPlaybackSupported();
}
/**
* Returns whether the MediaPlayer tracks have haptic channels. This is the same as
* AudioManager.hasHapticChannels, except it uses an already prepared MediaPlayer to avoid
* loading the metadata a second time.
*/
public boolean hasHapticChannels(MediaPlayer mp) {
try {
Trace.beginSection("Ringtone.hasHapticChannels");
for (MediaPlayer.TrackInfo trackInfo : mp.getTrackInfo()) {
if (trackInfo.hasHapticChannels()) {
return true;
}
}
} finally {
Trace.endSection();
}
return false;
}
}
}

View File

@@ -709,6 +709,7 @@ public class RingtoneManager {
return new Ringtone.Builder(context, Ringtone.MEDIA_SOUND, audioAttributes)
.setUri(ringtoneUri)
.setVolumeShaperConfig(volumeShaperConfig)
.setUseExactAudioAttributes(true) // May be using audio-coupled via attrs
.build();
}

View File

@@ -16,21 +16,30 @@
package com.android.mediaframeworktest.unit;
import static android.media.Ringtone.MEDIA_SOUND;
import static android.media.Ringtone.MEDIA_SOUND_AND_VIBRATION;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyObject;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.AssetFileDescriptor;
import android.media.AudioAttributes;
import android.media.AudioManager;
@@ -40,8 +49,12 @@ import android.media.Ringtone;
import android.media.audiofx.HapticGenerator;
import android.net.Uri;
import android.os.IBinder;
import android.os.VibrationAttributes;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.testing.TestableContext;
import android.util.ArrayMap;
import android.util.ArraySet;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
@@ -73,13 +86,24 @@ public class RingtoneTest {
private static final AudioAttributes RINGTONE_ATTRIBUTES =
audioAttributes(AudioAttributes.USAGE_NOTIFICATION_RINGTONE);
private static final AudioAttributes RINGTONE_ATTRIBUTES_WITH_HC =
new AudioAttributes.Builder(RINGTONE_ATTRIBUTES).setHapticChannelsMuted(false).build();
private static final VibrationAttributes RINGTONE_VIB_ATTRIBUTES =
new VibrationAttributes.Builder(RINGTONE_ATTRIBUTES).build();
private static final VibrationEffect VIBRATION_EFFECT =
VibrationEffect.createWaveform(new long[] { 0, 100, 50, 100}, -1);
private static final VibrationEffect VIBRATION_EFFECT_REPEATING =
VibrationEffect.createWaveform(new long[] { 0, 100, 50, 100, 50}, 1);
@Rule
public final RingtoneInjectablesTrackingTestRule
mMediaPlayerRule = new RingtoneInjectablesTrackingTestRule();
@Captor ArgumentCaptor<IBinder> mIBinderCaptor;
@Mock IRingtonePlayer mMockRemotePlayer;
@Captor private ArgumentCaptor<IBinder> mIBinderCaptor;
@Mock private IRingtonePlayer mMockRemotePlayer;
@Mock private Vibrator mMockVibrator;
private AudioManager mSpyAudioManager;
private TestableContext mContext;
@Before
@@ -87,11 +111,13 @@ public class RingtoneTest {
MockitoAnnotations.initMocks(this);
TestableContext testContext =
new TestableContext(InstrumentationRegistry.getTargetContext(), null);
testContext.getTestablePermissions().setPermission(Manifest.permission.VIBRATE,
PackageManager.PERMISSION_GRANTED);
AudioManager realAudioManager = testContext.getSystemService(AudioManager.class);
AudioManager spyAudioManager = spy(realAudioManager);
when(spyAudioManager.getRingtonePlayer()).thenReturn(mMockRemotePlayer);
testContext.addMockSystemService(Context.AUDIO_SERVICE, spyAudioManager);
mSpyAudioManager = spy(realAudioManager);
when(mSpyAudioManager.getRingtonePlayer()).thenReturn(mMockRemotePlayer);
testContext.addMockSystemService(AudioManager.class, mSpyAudioManager);
testContext.addMockSystemService(Vibrator.class, mMockVibrator);
mContext = spy(testContext);
}
@@ -100,12 +126,12 @@ public class RingtoneTest {
public void testRingtone_fullLifecycleUsingLocalMediaPlayer() throws Exception {
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
Ringtone ringtone =
newBuilder(Ringtone.MEDIA_SOUND, RINGTONE_ATTRIBUTES).setUri(SOUND_URI).build();
newBuilder(MEDIA_SOUND, RINGTONE_ATTRIBUTES).setUri(SOUND_URI).build();
assertThat(ringtone).isNotNull();
assertThat(ringtone.isUsingRemotePlayer()).isFalse();
// Verify all the properties.
assertThat(ringtone.getEnabledMedia()).isEqualTo(Ringtone.MEDIA_SOUND);
assertThat(ringtone.getEnabledMedia()).isEqualTo(MEDIA_SOUND);
assertThat(ringtone.getUri()).isEqualTo(SOUND_URI);
assertThat(ringtone.getAudioAttributes()).isEqualTo(RINGTONE_ATTRIBUTES);
assertThat(ringtone.getVolume()).isEqualTo(1.0f);
@@ -116,8 +142,7 @@ public class RingtoneTest {
assertThat(ringtone.isLocalOnly()).isFalse();
// Prepare
verifyLocalPlayerSetup(mockMediaPlayer, SOUND_URI,
audioAttributes(AudioAttributes.USAGE_NOTIFICATION_RINGTONE));
verifyLocalPlayerSetup(mockMediaPlayer, SOUND_URI, RINGTONE_ATTRIBUTES);
verify(mockMediaPlayer).setVolume(1.0f);
verify(mockMediaPlayer).setLooping(false);
verify(mockMediaPlayer).prepare();
@@ -129,7 +154,9 @@ public class RingtoneTest {
// Verify dynamic controls.
ringtone.setVolume(0.8f);
verify(mockMediaPlayer).setVolume(0.8f);
when(mockMediaPlayer.isLooping()).thenReturn(false);
ringtone.setLooping(true);
verify(mockMediaPlayer).isLooping();
verify(mockMediaPlayer).setLooping(true);
HapticGenerator mockHapticGenerator =
mMediaPlayerRule.expectHapticGenerator(mockMediaPlayer);
@@ -139,10 +166,52 @@ public class RingtoneTest {
// Release
ringtone.stop();
verifyLocalStop(mockMediaPlayer);
// This test is intended to strictly verify all interactions with MediaPlayer in a local
// playback case. This shouldn't be necessary in other tests that have the same basic
// setup.
verifyNoMoreInteractions(mockMediaPlayer);
verify(mockHapticGenerator).release();
verifyNoMoreInteractions(mockHapticGenerator);
verifyZeroInteractions(mMockRemotePlayer);
verifyZeroInteractions(mMockVibrator);
}
@Test
public void testRingtone_localMediaPlayerWithAudioCoupledOverride() throws Exception {
// Audio coupled playback is enabled in the incoming attributes, plus an instruction
// to leave the attributes alone. This test verifies that the attributes reach the
// media player without changing.
final AudioAttributes audioAttributes = RINGTONE_ATTRIBUTES_WITH_HC;
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
mMediaPlayerRule.setHasHapticChannels(mockMediaPlayer, true);
Ringtone ringtone =
newBuilder(MEDIA_SOUND, audioAttributes)
.setUri(SOUND_URI)
.setUseExactAudioAttributes(true)
.build();
assertThat(ringtone).isNotNull();
assertThat(ringtone.isUsingRemotePlayer()).isFalse();
// Verify all the properties.
assertThat(ringtone.getEnabledMedia()).isEqualTo(MEDIA_SOUND);
assertThat(ringtone.getUri()).isEqualTo(SOUND_URI);
assertThat(ringtone.getAudioAttributes()).isEqualTo(audioAttributes);
// Prepare
verifyLocalPlayerSetup(mockMediaPlayer, SOUND_URI, audioAttributes);
verify(mockMediaPlayer).prepare();
// Play
ringtone.play();
verifyLocalPlay(mockMediaPlayer);
// Release
ringtone.stop();
verifyLocalStop(mockMediaPlayer);
verifyZeroInteractions(mMockRemotePlayer);
verifyZeroInteractions(mMockVibrator);
}
@Test
@@ -150,14 +219,14 @@ public class RingtoneTest {
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
setupFileNotFound(mockMediaPlayer, SOUND_URI);
Ringtone ringtone =
newBuilder(Ringtone.MEDIA_SOUND, RINGTONE_ATTRIBUTES)
newBuilder(MEDIA_SOUND, RINGTONE_ATTRIBUTES)
.setUri(SOUND_URI)
.build();
assertThat(ringtone).isNotNull();
assertThat(ringtone.isUsingRemotePlayer()).isTrue();
// Verify all the properties.
assertThat(ringtone.getEnabledMedia()).isEqualTo(Ringtone.MEDIA_SOUND);
assertThat(ringtone.getEnabledMedia()).isEqualTo(MEDIA_SOUND);
assertThat(ringtone.getUri()).isEqualTo(SOUND_URI);
assertThat(ringtone.getAudioAttributes()).isEqualTo(RINGTONE_ATTRIBUTES);
assertThat(ringtone.getVolume()).isEqualTo(1.0f);
@@ -174,8 +243,8 @@ public class RingtoneTest {
// Delegates to remote media player.
ringtone.play();
verify(mMockRemotePlayer).playWithVolumeShaping(mIBinderCaptor.capture(), eq(SOUND_URI),
eq(audioAttributes(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)),
verify(mMockRemotePlayer).playRemoteRingtone(mIBinderCaptor.capture(), eq(SOUND_URI),
eq(RINGTONE_ATTRIBUTES), eq(false), eq(MEDIA_SOUND), isNull(),
eq(1.0f), eq(false), eq(false), isNull());
IBinder remoteToken = mIBinderCaptor.getValue();
@@ -191,6 +260,148 @@ public class RingtoneTest {
verify(mMockRemotePlayer).stop(remoteToken);
verifyNoMoreInteractions(mMockRemotePlayer);
verifyNoMoreInteractions(mockMediaPlayer);
verifyZeroInteractions(mMockVibrator);
}
@Test
public void testRingtone_localMediaWithVibration() throws Exception {
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
when(mMockVibrator.hasVibrator()).thenReturn(true);
Ringtone ringtone =
newBuilder(MEDIA_SOUND_AND_VIBRATION, RINGTONE_ATTRIBUTES)
.setUri(SOUND_URI)
.setVibrationEffect(VIBRATION_EFFECT)
.build();
assertThat(ringtone).isNotNull();
assertThat(ringtone.isUsingRemotePlayer()).isFalse();
verify(mMockVibrator).hasVibrator();
// Verify all the properties.
assertThat(ringtone.getEnabledMedia()).isEqualTo(MEDIA_SOUND_AND_VIBRATION);
assertThat(ringtone.getUri()).isEqualTo(SOUND_URI);
assertThat(ringtone.getVibrationEffect()).isEqualTo(VIBRATION_EFFECT);
// Prepare
// Uses attributes with haptic channels enabled, but will use the effect when there aren't
// any present.
verifyLocalPlayerSetup(mockMediaPlayer, SOUND_URI, RINGTONE_ATTRIBUTES_WITH_HC);
verify(mockMediaPlayer).setVolume(1.0f);
verify(mockMediaPlayer).setLooping(false);
verify(mockMediaPlayer).prepare();
// Play
ringtone.play();
verifyLocalPlay(mockMediaPlayer);
verify(mockMediaPlayer).isLooping(); // When starting the vibration.
verify(mMockVibrator).vibrate(VIBRATION_EFFECT, RINGTONE_VIB_ATTRIBUTES);
// Verify dynamic controls.
ringtone.setVolume(0.8f);
verify(mockMediaPlayer).setVolume(0.8f);
// Set looping doesn't affect an already-started vibration.
when(mockMediaPlayer.isLooping()).thenReturn(false); // Checks original
ringtone.setLooping(true);
verify(mockMediaPlayer).isPlaying(); // Vibration check.
verify(mockMediaPlayer, times(2)).isLooping(); // Current state, second isLooping call.
verify(mockMediaPlayer).setLooping(true);
// This is ignored because there's a vibration effect being used.
ringtone.setHapticGeneratorEnabled(true);
// Release
ringtone.stop();
verifyLocalStop(mockMediaPlayer);
verify(mMockVibrator).cancel(VibrationAttributes.USAGE_RINGTONE);
// This test is intended to strictly verify all interactions with MediaPlayer in a local
// playback case. This shouldn't be necessary in other tests that have the same basic
// setup.
verifyNoMoreInteractions(mockMediaPlayer);
verifyZeroInteractions(mMockRemotePlayer);
verifyNoMoreInteractions(mMockVibrator);
}
@Test
public void testRingtone_localMediaWithVibrationPrefersHapticChannels() throws Exception {
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
mMediaPlayerRule.setHasHapticChannels(mockMediaPlayer, true);
when(mMockVibrator.hasVibrator()).thenReturn(true);
Ringtone ringtone =
newBuilder(MEDIA_SOUND_AND_VIBRATION, RINGTONE_ATTRIBUTES)
.setUri(SOUND_URI)
.setVibrationEffect(VIBRATION_EFFECT)
.build();
assertThat(ringtone).isNotNull();
assertThat(ringtone.isUsingRemotePlayer()).isFalse();
verify(mMockVibrator).hasVibrator();
// Verify all the properties.
assertThat(ringtone.getEnabledMedia()).isEqualTo(MEDIA_SOUND_AND_VIBRATION);
assertThat(ringtone.getUri()).isEqualTo(SOUND_URI);
assertThat(ringtone.getVibrationEffect()).isEqualTo(VIBRATION_EFFECT);
// Prepare
// The attributes here have haptic channels enabled (unlike above)
verifyLocalPlayerSetup(mockMediaPlayer, SOUND_URI, RINGTONE_ATTRIBUTES_WITH_HC);
verify(mockMediaPlayer).prepare();
// Play
ringtone.play();
when(mockMediaPlayer.isPlaying()).thenReturn(true);
verifyLocalPlay(mockMediaPlayer);
// Release
ringtone.stop();
verifyLocalStop(mockMediaPlayer);
verifyZeroInteractions(mMockRemotePlayer);
// Nothing after the initial hasVibrator - it uses audio-coupled.
verifyNoMoreInteractions(mMockVibrator);
}
@Test
public void testRingtone_localMediaWithVibrationButSoundMuted() throws Exception {
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
mMediaPlayerRule.setHasHapticChannels(mockMediaPlayer, false);
doReturn(0).when(mSpyAudioManager)
.getStreamVolume(AudioAttributes.toLegacyStreamType(RINGTONE_ATTRIBUTES));
when(mMockVibrator.hasVibrator()).thenReturn(true);
Ringtone ringtone =
newBuilder(MEDIA_SOUND_AND_VIBRATION, RINGTONE_ATTRIBUTES)
.setUri(SOUND_URI)
.setVibrationEffect(VIBRATION_EFFECT)
.build();
assertThat(ringtone).isNotNull();
assertThat(ringtone.isUsingRemotePlayer()).isFalse();
verify(mMockVibrator).hasVibrator();
// Verify all the properties.
assertThat(ringtone.getEnabledMedia()).isEqualTo(MEDIA_SOUND_AND_VIBRATION);
assertThat(ringtone.getUri()).isEqualTo(SOUND_URI);
assertThat(ringtone.getVibrationEffect()).isEqualTo(VIBRATION_EFFECT);
// Prepare
// The attributes here have haptic channels enabled (unlike above)
verifyLocalPlayerSetup(mockMediaPlayer, SOUND_URI, RINGTONE_ATTRIBUTES_WITH_HC);
verify(mockMediaPlayer).prepare();
// Play
ringtone.play();
// The media player is never played, because sound is muted.
verify(mockMediaPlayer, never()).start();
when(mockMediaPlayer.isPlaying()).thenReturn(true);
verify(mMockVibrator).vibrate(VIBRATION_EFFECT, RINGTONE_VIB_ATTRIBUTES);
// Release
ringtone.stop();
verify(mockMediaPlayer).release();
verify(mMockVibrator).cancel(VibrationAttributes.USAGE_RINGTONE);
verifyZeroInteractions(mMockRemotePlayer);
// Nothing after the initial hasVibrator - it uses audio-coupled.
verifyNoMoreInteractions(mMockVibrator);
}
@Test
@@ -204,7 +415,7 @@ public class RingtoneTest {
.addOverride(com.android.internal.R.raw.fallbackring, testResourceFd);
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
Ringtone ringtone = newBuilder(Ringtone.MEDIA_SOUND, RINGTONE_ATTRIBUTES)
Ringtone ringtone = newBuilder(MEDIA_SOUND, RINGTONE_ATTRIBUTES)
.setUri(null)
.build();
assertThat(ringtone).isNotNull();
@@ -233,7 +444,7 @@ public class RingtoneTest {
public void testRingtone_nullMediaOnBuilderUsesFallbackViaRemote() throws Exception {
mContext.getOrCreateTestableResources()
.addOverride(com.android.internal.R.raw.fallbackring, null);
Ringtone ringtone = newBuilder(Ringtone.MEDIA_SOUND, RINGTONE_ATTRIBUTES)
Ringtone ringtone = newBuilder(MEDIA_SOUND, RINGTONE_ATTRIBUTES)
.setUri(null)
.setLooping(true) // distinct from haptic generator, to match plumbing
.build();
@@ -243,8 +454,9 @@ public class RingtoneTest {
assertThat(ringtone.isUsingRemotePlayer()).isTrue();
ringtone.play();
verify(mMockRemotePlayer).playWithVolumeShaping(mIBinderCaptor.capture(), isNull(),
eq(audioAttributes(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)),
verify(mMockRemotePlayer).playRemoteRingtone(mIBinderCaptor.capture(), isNull(),
eq(RINGTONE_ATTRIBUTES), eq(false),
eq(MEDIA_SOUND), isNull(),
eq(1.0f), eq(true), eq(false), isNull());
ringtone.stop();
verify(mMockRemotePlayer).stop(mIBinderCaptor.getValue());
@@ -255,7 +467,7 @@ public class RingtoneTest {
public void testRingtone_noMediaSetOnBuilderFallbackFailsAndNoRemote() throws Exception {
mContext.getOrCreateTestableResources()
.addOverride(com.android.internal.R.raw.fallbackring, null);
Ringtone ringtone = newBuilder(Ringtone.MEDIA_SOUND, RINGTONE_ATTRIBUTES)
Ringtone ringtone = newBuilder(MEDIA_SOUND, RINGTONE_ATTRIBUTES)
.setUri(null)
.setLocalOnly()
.build();
@@ -308,11 +520,12 @@ public class RingtoneTest {
}
private void verifyLocalPlay(MediaPlayer mockMediaPlayer) {
verify(mockMediaPlayer).setOnCompletionListener(anyObject());
verify(mockMediaPlayer).setOnCompletionListener(any());
verify(mockMediaPlayer).start();
}
private void verifyLocalStop(MediaPlayer mockMediaPlayer) {
verify(mockMediaPlayer).stop();
verify(mockMediaPlayer).setOnCompletionListener(isNull());
verify(mockMediaPlayer).reset();
verify(mockMediaPlayer).release();
@@ -340,6 +553,9 @@ public class RingtoneTest {
// Similar to media players, but for haptic generator, which also needs releasing.
private Map<MediaPlayer, HapticGenerator> mMockHapticGeneratorMap = new ArrayMap<>();
// Media players with haptic channels.
private ArraySet<MediaPlayer> mHapticChannels = new ArraySet<>();
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@@ -357,8 +573,15 @@ public class RingtoneTest {
};
}
private MediaPlayer expectLocalMediaPlayer() {
MediaPlayer mockMediaPlayer = Mockito.mock(MediaPlayerMockableNatives.class);
private TestMediaPlayer expectLocalMediaPlayer() {
TestMediaPlayer mockMediaPlayer = Mockito.mock(TestMediaPlayer.class);
// Delegate to simulated methods. This means they can be verified but also reflect
// realistic transitions from the TestMediaPlayer.
doCallRealMethod().when(mockMediaPlayer).start();
doCallRealMethod().when(mockMediaPlayer).stop();
doCallRealMethod().when(mockMediaPlayer).setLooping(anyBoolean());
when(mockMediaPlayer.isLooping()).thenCallRealMethod();
when(mockMediaPlayer.isLooping()).thenCallRealMethod();
mMockMediaPlayerQueue.add(mockMediaPlayer);
return mockMediaPlayer;
}
@@ -373,6 +596,14 @@ public class RingtoneTest {
return mockHapticGenerator;
}
private void setHasHapticChannels(MediaPlayer mp, boolean hasHapticChannels) {
if (hasHapticChannels) {
mHapticChannels.add(mp);
} else {
mHapticChannels.remove(mp);
}
}
private class TestInjectables extends Ringtone.Injectables {
@Override
public MediaPlayer newMediaPlayer() {
@@ -397,14 +628,61 @@ public class RingtoneTest {
.isNotNull();
return mockHapticGenerator;
}
@Override
public boolean isHapticPlaybackSupported() {
return true;
}
@Override
public boolean hasHapticChannels(MediaPlayer mp) {
return mHapticChannels.contains(mp);
}
}
}
/** Mocks don't work directly on native calls, but if they're overridden then it does work. */
private static class MediaPlayerMockableNatives extends MediaPlayer {
/**
* MediaPlayer relies on a native backend and so its necessary to intercept calls from
* fake usage hitting them.
*
* Mocks don't work directly on native calls, but if they're overridden then it does work.
* Some basic state faking is also done to make the mocks more realistic.
*/
private static class TestMediaPlayer extends MediaPlayer {
private boolean mIsPlaying = false;
private boolean mIsLooping = false;
@Override
public void start() {
mIsPlaying = true;
}
@Override
public void stop() {
mIsPlaying = false;
}
@Override
public void setLooping(boolean value) {
throw new IllegalStateException("Expected mock to intercept");
mIsLooping = value;
}
@Override
public boolean isLooping() {
return mIsLooping;
}
@Override
public boolean isPlaying() {
return mIsPlaying;
}
void simulatePlayingFinished() {
if (!mIsPlaying) {
throw new IllegalStateException(
"Attempted to pretend playing finished when not playing");
}
mIsPlaying = false;
}
}
}

View File

@@ -16,6 +16,9 @@
package com.android.systemui.media;
import static java.util.Objects.requireNonNull;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ContentResolver;
import android.content.Context;
@@ -34,6 +37,7 @@ import android.os.Process;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.UserHandle;
import android.os.VibrationEffect;
import android.provider.MediaStore;
import android.util.Log;
@@ -86,21 +90,11 @@ public class RingtonePlayer implements CoreStartable {
*/
private class Client implements IBinder.DeathRecipient {
private final IBinder mToken;
private final Ringtone mRingtone;
private Ringtone mRingtone;
public Client(IBinder token, Uri uri, UserHandle user, AudioAttributes aa) {
this(token, uri, user, aa, null);
}
Client(IBinder token, Uri uri, UserHandle user, AudioAttributes aa,
@Nullable VolumeShaper.Configuration volumeShaperConfig) {
mToken = token;
mRingtone = new Ringtone.Builder(getContextForUser(user), Ringtone.MEDIA_SOUND, aa)
.setUri(uri)
.setLocalOnly()
.setVolumeShaperConfig(volumeShaperConfig)
.build();
Client(@NonNull IBinder token, @NonNull Ringtone ringtone) {
mToken = requireNonNull(token);
mRingtone = requireNonNull(ringtone);
}
@Override
@@ -117,12 +111,16 @@ public class RingtonePlayer implements CoreStartable {
@Override
public void play(IBinder token, Uri uri, AudioAttributes aa, float volume, boolean looping)
throws RemoteException {
playWithVolumeShaping(token, uri, aa, volume, looping, /* hapticGenerator= */ false,
playRemoteRingtone(token, uri, aa, true, Ringtone.MEDIA_SOUND,
null, volume, looping, /* hapticGenerator= */ false,
null);
}
@Override
public void playWithVolumeShaping(IBinder token, Uri uri, AudioAttributes aa, float volume,
public void playRemoteRingtone(IBinder token, Uri uri, AudioAttributes aa,
boolean useExactAudioAttributes,
@Ringtone.RingtoneMedia int enabledMedia, @Nullable VibrationEffect vibrationEffect,
float volume,
boolean looping, boolean isHapticGeneratorEnabled,
@Nullable VolumeShaper.Configuration volumeShaperConfig)
throws RemoteException {
@@ -130,19 +128,39 @@ public class RingtonePlayer implements CoreStartable {
Log.d(TAG, "play(token=" + token + ", uri=" + uri + ", uid="
+ Binder.getCallingUid() + ")");
}
// Don't hold the lock while constructing the ringtone, since it can be slow. The caller
// shouldn't call play on the same ringtone from 2 threads, so this shouldn't race and
// waste the build.
Client client;
synchronized (mClients) {
client = mClients.get(token);
if (client == null) {
final UserHandle user = Binder.getCallingUserHandle();
client = new Client(token, uri, user, aa, volumeShaperConfig);
token.linkToDeath(client, 0);
mClients.put(token, client);
}
if (client == null) {
final UserHandle user = Binder.getCallingUserHandle();
Ringtone ringtone = new Ringtone.Builder(getContextForUser(user), enabledMedia, aa)
.setLocalOnly()
.setUri(uri)
.setLooping(looping)
.setInitialSoundVolume(volume)
.setUseExactAudioAttributes(useExactAudioAttributes)
.setEnableHapticGenerator(isHapticGeneratorEnabled)
.setVibrationEffect(vibrationEffect)
.setVolumeShaperConfig(volumeShaperConfig)
.build();
if (ringtone == null) {
return;
}
synchronized (mClients) {
client = mClients.get(token);
if (client == null) {
client = new Client(token, ringtone);
token.linkToDeath(client, 0);
mClients.put(token, client);
}
}
}
client.mRingtone.setLooping(looping);
client.mRingtone.setVolume(volume);
client.mRingtone.setHapticGeneratorEnabled(isHapticGeneratorEnabled);
// Ensure the client is initialized outside the all-clients lock, as it can be slow.
client.mRingtone.play();
}