Merge "Support vibration-only via Ringtone player."

This commit is contained in:
Simon Bowden
2023-05-14 23:54:40 +00:00
committed by Android (Google) Code Review
3 changed files with 312 additions and 61 deletions

View File

@@ -39,27 +39,23 @@ 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> sActiveMediaPlayers = new ArrayList<>();
private final MediaPlayer mMediaPlayer;
private final AudioAttributes mAudioAttributes;
private final VibrationAttributes mVibrationAttributes;
private final Ringtone.RingtonePlayer mVibrationPlayer;
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, @NonNull Vibrator vibrator,
@Nullable VibrationEffect vibrationEffect) {
@Nullable VolumeShaper volumeShaper,
@Nullable Ringtone.RingtonePlayer vibrationPlayer) {
Objects.requireNonNull(mediaPlayer);
Objects.requireNonNull(audioAttributes);
Objects.requireNonNull(injectables);
@@ -69,11 +65,8 @@ public class LocalRingtonePlayer
mInjectables = injectables;
mAudioManager = audioManager;
mVolumeShaper = volumeShaper;
mVibrator = vibrator;
mVibrationEffect = vibrationEffect;
mVibrationPlayer = vibrationPlayer;
mHapticGenerator = hapticGenerator;
mVibrationAttributes = (mVibrationEffect == null) ? null :
new VibrationAttributes.Builder(audioAttributes).build();
}
/**
@@ -81,9 +74,11 @@ public class LocalRingtonePlayer
* loaded in the local player.
*/
@Nullable
static LocalRingtonePlayer create(@NonNull Context context,
@NonNull AudioManager audioManager, @NonNull Vibrator vibrator, @NonNull Uri soundUri,
static Ringtone.RingtonePlayer create(@NonNull Context context,
@NonNull AudioManager audioManager, @NonNull Vibrator vibrator,
@NonNull Uri soundUri,
@NonNull AudioAttributes audioAttributes,
boolean isVibrationOnly,
@Nullable VibrationEffect vibrationEffect,
@NonNull Ringtone.Injectables injectables,
@Nullable VolumeShaper.Configuration volumeShaperConfig,
@@ -100,7 +95,7 @@ public class LocalRingtonePlayer
mediaPlayer.setAudioAttributes(audioAttributes);
mediaPlayer.setPreferredDevice(preferredDevice);
mediaPlayer.setLooping(initialLooping);
mediaPlayer.setVolume(initialVolume);
mediaPlayer.setVolume(isVibrationOnly ? 0 : initialVolume);
if (initialHapticGeneratorEnabled) {
hapticGenerator = injectables.createHapticGenerator(mediaPlayer);
if (hapticGenerator != null) {
@@ -121,8 +116,16 @@ public class LocalRingtonePlayer
vibrationEffect = null;
}
}
VibrationEffectPlayer vibrationEffectPlayer = (vibrationEffect == null) ? null :
new VibrationEffectPlayer(
vibrationEffect, audioAttributes, vibrator, initialLooping);
if (isVibrationOnly && vibrationEffectPlayer != null) {
// Abandon the media player now that it's confirmed to not have haptic channels.
mediaPlayer.release();
return vibrationEffectPlayer;
}
return new LocalRingtonePlayer(mediaPlayer, audioAttributes, injectables, audioManager,
hapticGenerator, volumeShaper, vibrator, vibrationEffect);
hapticGenerator, volumeShaper, vibrationEffectPlayer);
} catch (SecurityException | IOException e) {
if (hapticGenerator != null) {
hapticGenerator.release();
@@ -179,8 +182,11 @@ public class LocalRingtonePlayer
vibrationEffect = null;
}
}
VibrationEffectPlayer vibrationEffectPlayer = (vibrationEffect == null) ? null :
new VibrationEffectPlayer(
vibrationEffect, audioAttributes, vibrator, initialLooping);
return new LocalRingtonePlayer(mediaPlayer, audioAttributes, injectables, audioManager,
/* hapticGenerator= */ null, volumeShaper, vibrator, vibrationEffect);
/* hapticGenerator= */ null, volumeShaper, vibrationEffectPlayer);
} catch (SecurityException | IOException e) {
Log.e(TAG, "Failed to open fallback ringtone");
// TODO: vibration-effect-only / no-sound LocalRingtonePlayer.
@@ -217,33 +223,8 @@ public class LocalRingtonePlayer
}
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);
}
if (mVibrationPlayer != null) {
mVibrationPlayer.play();
}
}
@@ -260,7 +241,13 @@ public class LocalRingtonePlayer
try {
mMediaPlayer.stop();
} finally {
stopVibration(); // Won't throw: catches exceptions.
if (mVibrationPlayer != null) {
try {
mVibrationPlayer.stopAndRelease();
} catch (Exception e) {
Log.e(TAG, "Exception stopping ringtone vibration", e);
}
}
if (mHapticGenerator != null) {
mHapticGenerator.release();
}
@@ -282,25 +269,19 @@ public class LocalRingtonePlayer
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();
}
if (mVibrationPlayer != null) {
mVibrationPlayer.setLooping(looping);
}
}
@Override
public void setHapticGeneratorEnabled(boolean enabled) {
if (mVibrationEffect != null) {
// Ignore haptic generator changes if a vibration effect is present. The decision to
if (mVibrationPlayer != null) {
// Ignore haptic generator changes if a vibration player is present. The decision to
// use one or the other happens before this object is constructed.
return;
}
if (enabled && mHapticGenerator == null) {
if (enabled && mHapticGenerator == null && !hasHapticChannels()) {
mHapticGenerator = mInjectables.createHapticGenerator(mMediaPlayer);
}
if (mHapticGenerator != null) {
@@ -311,6 +292,7 @@ public class LocalRingtonePlayer
@Override
public void setVolume(float volume) {
mMediaPlayer.setVolume(volume);
// no effect on vibration player
}
@Override
@@ -324,5 +306,103 @@ public class LocalRingtonePlayer
sActiveMediaPlayers.remove(this);
}
mp.setOnCompletionListener(null); // Help the Java GC: break the refcount cycle.
// No effect on vibration: either it's looping and this callback only happens when stopped,
// or it's not looping, in which case the vibration should play to its own completion.
}
/** A RingtonePlayer that only plays a VibrationEffect. */
static class VibrationEffectPlayer implements Ringtone.RingtonePlayer {
private static final int VIBRATION_LOOP_DELAY_MS = 200;
private final VibrationEffect mVibrationEffect;
private final VibrationAttributes mVibrationAttributes;
private final Vibrator mVibrator;
private boolean mIsLooping;
private boolean mStartedVibration;
VibrationEffectPlayer(@NonNull VibrationEffect vibrationEffect,
@NonNull AudioAttributes audioAttributes,
@NonNull Vibrator vibrator, boolean initialLooping) {
mVibrationEffect = vibrationEffect;
mVibrationAttributes = new VibrationAttributes.Builder(audioAttributes).build();
mVibrator = vibrator;
mIsLooping = initialLooping;
}
@Override
public boolean play() {
if (!mStartedVibration) {
try {
// Adjust the vibration effect to loop.
VibrationEffect loopAdjustedEffect =
mVibrationEffect.applyRepeatingIndefinitely(
mIsLooping, 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 " + (mIsLooping ? "looping " : "") + "vibration "
+ "for ringtone: " + mVibrationEffect, e);
return false;
}
}
return true;
}
@Override
public boolean isPlaying() {
return mStartedVibration;
}
@Override
public void stopAndRelease() {
if (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 void setPreferredDevice(AudioDeviceInfo audioDeviceInfo) {
// no-op
}
@Override
public void setLooping(boolean looping) {
if (looping == mIsLooping) {
return;
}
mIsLooping = looping;
if (mStartedVibration) {
if (!mIsLooping) {
// Was looping, stop looping
stopAndRelease();
}
// Else was not looping, but can't interfere with a running vibration without
// restarting it, and don't know if it was finished. So do nothing: apps shouldn't
// toggle looping after calling play anyway.
}
}
@Override
public void setHapticGeneratorEnabled(boolean enabled) {
// n/a
}
@Override
public void setVolume(float volume) {
// n/a
}
@Override
public boolean hasHapticChannels() {
return false;
}
}
}

View File

@@ -286,14 +286,29 @@ public class Ringtone {
stopAndReleaseActivePlayer();
}
boolean vibrationOnly = (mEnabledMedia & MEDIA_ALL) == MEDIA_VIBRATION;
// Vibration can come from the audio file if using haptic generator or if haptic
// channels are a possibility.
boolean maybeAudioVibration = mUri != null && mInjectables.isHapticPlaybackSupported()
&& (mHapticGeneratorEnabled || !mAudioAttributes.areHapticChannelsMuted());
// VibrationEffect only, use the simplified player without checking for haptic channels.
if (vibrationOnly && !maybeAudioVibration && mVibrationEffect != null) {
mActivePlayer = new LocalRingtonePlayer.VibrationEffectPlayer(
mVibrationEffect, mAudioAttributes, mVibrator, mIsLooping);
return true;
}
AudioDeviceInfo preferredDevice =
mPreferBuiltinDevice ? getBuiltinDevice(mAudioManager) : null;
if (mUri != null) {
mActivePlayer = LocalRingtonePlayer.create(mContext, mAudioManager, mVibrator, mUri,
mAudioAttributes, mVibrationEffect, mInjectables, mVolumeShaperConfig,
preferredDevice, mHapticGeneratorEnabled, mIsLooping, mVolume);
mAudioAttributes, vibrationOnly, mVibrationEffect, mInjectables,
mVolumeShaperConfig, preferredDevice, mHapticGeneratorEnabled, mIsLooping,
mVolume);
} else {
// Using the remote player won't help play a null Uri. Revert straight to fallback.
// The vibration-only case was already covered above.
mActivePlayer = createFallbackRingtonePlayer();
// Fall through to attempting remote fallback play if null.
}
@@ -388,6 +403,10 @@ public class Ringtone {
* corresponds to no attenuation being applied.
*/
public void setVolume(float volume) {
// Ignore if sound not enabled.
if ((mEnabledMedia & MEDIA_SOUND) == 0) {
return;
}
if (volume < 0.0f) {
volume = 0.0f;
} else if (volume > 1.0f) {

View File

@@ -18,6 +18,7 @@ package com.android.mediaframeworktest.unit;
import static android.media.Ringtone.MEDIA_SOUND;
import static android.media.Ringtone.MEDIA_SOUND_AND_VIBRATION;
import static android.media.Ringtone.MEDIA_VIBRATION;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
@@ -31,7 +32,6 @@ 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;
@@ -293,7 +293,6 @@ public class RingtoneTest {
ringtone.play();
verifyLocalPlay(mockMediaPlayer);
verify(mockMediaPlayer).isLooping(); // When starting the vibration.
verify(mMockVibrator).vibrate(VIBRATION_EFFECT, RINGTONE_VIB_ATTRIBUTES);
// Verify dynamic controls.
@@ -303,8 +302,7 @@ public class RingtoneTest {
// 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).isLooping();
verify(mockMediaPlayer).setLooping(true);
// This is ignored because there's a vibration effect being used.
@@ -323,6 +321,160 @@ public class RingtoneTest {
verifyNoMoreInteractions(mMockVibrator);
}
@Test
public void testRingtone_localMediaWithVibrationOnly() throws Exception {
when(mMockVibrator.hasVibrator()).thenReturn(true);
Ringtone ringtone =
newBuilder(MEDIA_VIBRATION, RINGTONE_ATTRIBUTES)
// TODO: set sound uri too in diff test
.setVibrationEffect(VIBRATION_EFFECT)
.build();
assertThat(ringtone).isNotNull();
assertThat(ringtone.isUsingRemotePlayer()).isFalse();
verify(mMockVibrator).hasVibrator();
// Verify all the properties.
assertThat(ringtone.getEnabledMedia()).isEqualTo(MEDIA_VIBRATION);
assertThat(ringtone.getUri()).isNull();
assertThat(ringtone.getVibrationEffect()).isEqualTo(VIBRATION_EFFECT);
// Play
ringtone.play();
verify(mMockVibrator).vibrate(VIBRATION_EFFECT, RINGTONE_VIB_ATTRIBUTES);
// Verify dynamic controls (no-op without sound)
ringtone.setVolume(0.8f);
// Set looping doesn't affect an already-started vibration.
ringtone.setLooping(true);
// This is ignored because there's a vibration effect being used and no sound.
ringtone.setHapticGeneratorEnabled(true);
// Release
ringtone.stop();
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.
verifyZeroInteractions(mMockRemotePlayer);
verifyNoMoreInteractions(mMockVibrator);
}
@Test
public void testRingtone_localMediaWithVibrationOnlyAndSoundUriNoHapticChannels()
throws Exception {
// A media player will still be created for vibration-only because the vibration can come
// from haptic channels on the sound file (although in this case it doesn't).
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
mMediaPlayerRule.setHasHapticChannels(mockMediaPlayer, false);
when(mMockVibrator.hasVibrator()).thenReturn(true);
Ringtone ringtone =
newBuilder(MEDIA_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_VIBRATION);
assertThat(ringtone.getUri()).isEqualTo(SOUND_URI);
assertThat(ringtone.getVibrationEffect()).isEqualTo(VIBRATION_EFFECT);
// Prepare
// Uses attributes with haptic channels enabled, but will abandon the MediaPlayer when it
// knows there aren't any.
verifyLocalPlayerSetup(mockMediaPlayer, SOUND_URI, RINGTONE_ATTRIBUTES_WITH_HC);
verify(mockMediaPlayer).setVolume(0.0f); // Vibration-only: sound muted.
verify(mockMediaPlayer).setLooping(false);
verify(mockMediaPlayer).prepare();
verify(mockMediaPlayer).release(); // abandoned: no haptic channels.
// Play
ringtone.play();
verify(mMockVibrator).vibrate(VIBRATION_EFFECT, RINGTONE_VIB_ATTRIBUTES);
// Verify dynamic controls (no-op without sound)
ringtone.setVolume(0.8f);
// Set looping doesn't affect an already-started vibration.
ringtone.setLooping(true);
// This is ignored because there's a vibration effect being used and no sound.
ringtone.setHapticGeneratorEnabled(true);
// Release
ringtone.stop();
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.
verifyZeroInteractions(mMockRemotePlayer);
verifyNoMoreInteractions(mMockVibrator);
verifyNoMoreInteractions(mockMediaPlayer);
}
@Test
public void testRingtone_localMediaWithVibrationOnlyAndSoundUriWithHapticChannels()
throws Exception {
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
when(mMockVibrator.hasVibrator()).thenReturn(true);
mMediaPlayerRule.setHasHapticChannels(mockMediaPlayer, true);
Ringtone ringtone =
newBuilder(MEDIA_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_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(0.0f); // Vibration-only: sound muted.
verify(mockMediaPlayer).setLooping(false);
verify(mockMediaPlayer).prepare();
// Play
ringtone.play();
// Vibrator.vibrate isn't called because the vibration comes from the sound.
verifyLocalPlay(mockMediaPlayer);
// Verify dynamic controls (no-op without sound)
ringtone.setVolume(0.8f);
when(mockMediaPlayer.isLooping()).thenReturn(false); // Checks original
ringtone.setLooping(true);
verify(mockMediaPlayer).isLooping();
verify(mockMediaPlayer).setLooping(true);
// This is ignored because it's using haptic channels.
ringtone.setHapticGeneratorEnabled(true);
// 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.
verifyZeroInteractions(mMockRemotePlayer);
verifyZeroInteractions(mMockVibrator);
}
@Test
public void testRingtone_localMediaWithVibrationPrefersHapticChannels() throws Exception {
MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();