Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit ece5be62 authored by Simon Bowden's avatar Simon Bowden
Browse files

Support vibration-only via Ringtone player.

Bug: 261571543
Test: unit
Change-Id: Ia0f327f9d3a3a61fa3b42465f8b622c8c7473257
parent 93455124
Loading
Loading
Loading
Loading
+135 −55
Original line number Original line Diff line number Diff line
@@ -39,27 +39,23 @@ import java.util.Objects;
public class LocalRingtonePlayer
public class LocalRingtonePlayer
        implements Ringtone.RingtonePlayer, MediaPlayer.OnCompletionListener {
        implements Ringtone.RingtonePlayer, MediaPlayer.OnCompletionListener {
    private static final String TAG = "LocalRingtonePlayer";
    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.
    // keep references on active Ringtones until stopped or completion listener called.
    private static final ArrayList<LocalRingtonePlayer> sActiveMediaPlayers = new ArrayList<>();
    private static final ArrayList<LocalRingtonePlayer> sActiveMediaPlayers = new ArrayList<>();


    private final MediaPlayer mMediaPlayer;
    private final MediaPlayer mMediaPlayer;
    private final AudioAttributes mAudioAttributes;
    private final AudioAttributes mAudioAttributes;
    private final VibrationAttributes mVibrationAttributes;
    private final Ringtone.RingtonePlayer mVibrationPlayer;
    private final Ringtone.Injectables mInjectables;
    private final Ringtone.Injectables mInjectables;
    private final AudioManager mAudioManager;
    private final AudioManager mAudioManager;
    private final VolumeShaper mVolumeShaper;
    private final VolumeShaper mVolumeShaper;
    private final Vibrator mVibrator;
    private final VibrationEffect mVibrationEffect;
    private HapticGenerator mHapticGenerator;
    private HapticGenerator mHapticGenerator;
    private boolean mStartedVibration;


    private LocalRingtonePlayer(@NonNull MediaPlayer mediaPlayer,
    private LocalRingtonePlayer(@NonNull MediaPlayer mediaPlayer,
            @NonNull AudioAttributes audioAttributes, @NonNull Ringtone.Injectables injectables,
            @NonNull AudioAttributes audioAttributes, @NonNull Ringtone.Injectables injectables,
            @NonNull AudioManager audioManager, @Nullable HapticGenerator hapticGenerator,
            @NonNull AudioManager audioManager, @Nullable HapticGenerator hapticGenerator,
            @Nullable VolumeShaper volumeShaper, @NonNull Vibrator vibrator,
            @Nullable VolumeShaper volumeShaper,
            @Nullable VibrationEffect vibrationEffect) {
            @Nullable Ringtone.RingtonePlayer vibrationPlayer) {
        Objects.requireNonNull(mediaPlayer);
        Objects.requireNonNull(mediaPlayer);
        Objects.requireNonNull(audioAttributes);
        Objects.requireNonNull(audioAttributes);
        Objects.requireNonNull(injectables);
        Objects.requireNonNull(injectables);
@@ -69,11 +65,8 @@ public class LocalRingtonePlayer
        mInjectables = injectables;
        mInjectables = injectables;
        mAudioManager = audioManager;
        mAudioManager = audioManager;
        mVolumeShaper = volumeShaper;
        mVolumeShaper = volumeShaper;
        mVibrator = vibrator;
        mVibrationPlayer = vibrationPlayer;
        mVibrationEffect = vibrationEffect;
        mHapticGenerator = hapticGenerator;
        mHapticGenerator = hapticGenerator;
        mVibrationAttributes = (mVibrationEffect == null) ? null :
                new VibrationAttributes.Builder(audioAttributes).build();
    }
    }


    /**
    /**
@@ -81,9 +74,11 @@ public class LocalRingtonePlayer
     * loaded in the local player.
     * loaded in the local player.
     */
     */
    @Nullable
    @Nullable
    static LocalRingtonePlayer create(@NonNull Context context,
    static Ringtone.RingtonePlayer create(@NonNull Context context,
            @NonNull AudioManager audioManager, @NonNull Vibrator vibrator, @NonNull Uri soundUri,
            @NonNull AudioManager audioManager, @NonNull Vibrator vibrator,
            @NonNull Uri soundUri,
            @NonNull AudioAttributes audioAttributes,
            @NonNull AudioAttributes audioAttributes,
            boolean isVibrationOnly,
            @Nullable VibrationEffect vibrationEffect,
            @Nullable VibrationEffect vibrationEffect,
            @NonNull Ringtone.Injectables injectables,
            @NonNull Ringtone.Injectables injectables,
            @Nullable VolumeShaper.Configuration volumeShaperConfig,
            @Nullable VolumeShaper.Configuration volumeShaperConfig,
@@ -100,7 +95,7 @@ public class LocalRingtonePlayer
            mediaPlayer.setAudioAttributes(audioAttributes);
            mediaPlayer.setAudioAttributes(audioAttributes);
            mediaPlayer.setPreferredDevice(preferredDevice);
            mediaPlayer.setPreferredDevice(preferredDevice);
            mediaPlayer.setLooping(initialLooping);
            mediaPlayer.setLooping(initialLooping);
            mediaPlayer.setVolume(initialVolume);
            mediaPlayer.setVolume(isVibrationOnly ? 0 : initialVolume);
            if (initialHapticGeneratorEnabled) {
            if (initialHapticGeneratorEnabled) {
                hapticGenerator = injectables.createHapticGenerator(mediaPlayer);
                hapticGenerator = injectables.createHapticGenerator(mediaPlayer);
                if (hapticGenerator != null) {
                if (hapticGenerator != null) {
@@ -121,8 +116,16 @@ public class LocalRingtonePlayer
                    vibrationEffect = null;
                    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,
            return new LocalRingtonePlayer(mediaPlayer, audioAttributes, injectables, audioManager,
                    hapticGenerator, volumeShaper, vibrator, vibrationEffect);
                    hapticGenerator, volumeShaper, vibrationEffectPlayer);
        } catch (SecurityException | IOException e) {
        } catch (SecurityException | IOException e) {
            if (hapticGenerator != null) {
            if (hapticGenerator != null) {
                hapticGenerator.release();
                hapticGenerator.release();
@@ -179,8 +182,11 @@ public class LocalRingtonePlayer
                    vibrationEffect = null;
                    vibrationEffect = null;
                }
                }
            }
            }
            VibrationEffectPlayer vibrationEffectPlayer = (vibrationEffect == null) ? null :
                    new VibrationEffectPlayer(
                            vibrationEffect, audioAttributes, vibrator, initialLooping);
            return new LocalRingtonePlayer(mediaPlayer, audioAttributes,  injectables, audioManager,
            return new LocalRingtonePlayer(mediaPlayer, audioAttributes,  injectables, audioManager,
                    /* hapticGenerator= */ null, volumeShaper, vibrator, vibrationEffect);
                    /* hapticGenerator= */ null, volumeShaper, vibrationEffectPlayer);
        } catch (SecurityException | IOException e) {
        } catch (SecurityException | IOException e) {
            Log.e(TAG, "Failed to open fallback ringtone");
            Log.e(TAG, "Failed to open fallback ringtone");
            // TODO: vibration-effect-only / no-sound LocalRingtonePlayer.
            // TODO: vibration-effect-only / no-sound LocalRingtonePlayer.
@@ -217,33 +223,8 @@ public class LocalRingtonePlayer
    }
    }


    private void maybeStartVibration() {
    private void maybeStartVibration() {
        if (mVibrationEffect != null && !mStartedVibration) {
        if (mVibrationPlayer != null) {
            boolean isLooping = mMediaPlayer.isLooping();
            mVibrationPlayer.play();
            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);
            }
        }
        }
    }
    }


@@ -260,7 +241,13 @@ public class LocalRingtonePlayer
        try {
        try {
            mMediaPlayer.stop();
            mMediaPlayer.stop();
        } finally {
        } 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) {
            if (mHapticGenerator != null) {
                mHapticGenerator.release();
                mHapticGenerator.release();
            }
            }
@@ -282,25 +269,19 @@ public class LocalRingtonePlayer
            return;
            return;
        }
        }
        mMediaPlayer.setLooping(looping);
        mMediaPlayer.setLooping(looping);
        // If transitioning from looping to not-looping during play, then cancel the vibration.
        if (mVibrationPlayer != null) {
        if (mVibrationEffect != null && mMediaPlayer.isPlaying()) {
            mVibrationPlayer.setLooping(looping);
            if (wasLooping) {
                stopVibration();
            } else {
                // Won't restart the vibration to be looping if it was already started.
                maybeStartVibration();
            }
        }
        }
    }
    }


    @Override
    @Override
    public void setHapticGeneratorEnabled(boolean enabled) {
    public void setHapticGeneratorEnabled(boolean enabled) {
        if (mVibrationEffect != null) {
        if (mVibrationPlayer != null) {
            // Ignore haptic generator changes if a vibration effect is present. The decision to
            // Ignore haptic generator changes if a vibration player is present. The decision to
            // use one or the other happens before this object is constructed.
            // use one or the other happens before this object is constructed.
            return;
            return;
        }
        }
        if (enabled && mHapticGenerator == null) {
        if (enabled && mHapticGenerator == null && !hasHapticChannels()) {
            mHapticGenerator = mInjectables.createHapticGenerator(mMediaPlayer);
            mHapticGenerator = mInjectables.createHapticGenerator(mMediaPlayer);
        }
        }
        if (mHapticGenerator != null) {
        if (mHapticGenerator != null) {
@@ -311,6 +292,7 @@ public class LocalRingtonePlayer
    @Override
    @Override
    public void setVolume(float volume) {
    public void setVolume(float volume) {
        mMediaPlayer.setVolume(volume);
        mMediaPlayer.setVolume(volume);
        // no effect on vibration player
    }
    }


    @Override
    @Override
@@ -324,5 +306,103 @@ public class LocalRingtonePlayer
            sActiveMediaPlayers.remove(this);
            sActiveMediaPlayers.remove(this);
        }
        }
        mp.setOnCompletionListener(null); // Help the Java GC: break the refcount cycle.
        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;
        }
    }
    }
}
}
+21 −2
Original line number Original line Diff line number Diff line
@@ -286,14 +286,29 @@ public class Ringtone {
                stopAndReleaseActivePlayer();
                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 =
            AudioDeviceInfo preferredDevice =
                    mPreferBuiltinDevice ? getBuiltinDevice(mAudioManager) : null;
                    mPreferBuiltinDevice ? getBuiltinDevice(mAudioManager) : null;
            if (mUri != null) {
            if (mUri != null) {
                mActivePlayer = LocalRingtonePlayer.create(mContext, mAudioManager, mVibrator, mUri,
                mActivePlayer = LocalRingtonePlayer.create(mContext, mAudioManager, mVibrator, mUri,
                        mAudioAttributes, mVibrationEffect, mInjectables, mVolumeShaperConfig,
                        mAudioAttributes, vibrationOnly, mVibrationEffect, mInjectables,
                        preferredDevice, mHapticGeneratorEnabled, mIsLooping, mVolume);
                        mVolumeShaperConfig, preferredDevice, mHapticGeneratorEnabled, mIsLooping,
                        mVolume);
            } else {
            } else {
                // Using the remote player won't help play a null Uri. Revert straight to fallback.
                // 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();
                mActivePlayer = createFallbackRingtonePlayer();
                // Fall through to attempting remote fallback play if null.
                // Fall through to attempting remote fallback play if null.
            }
            }
@@ -388,6 +403,10 @@ public class Ringtone {
     *   corresponds to no attenuation being applied.
     *   corresponds to no attenuation being applied.
     */
     */
    public void setVolume(float volume) {
    public void setVolume(float volume) {
        // Ignore if sound not enabled.
        if ((mEnabledMedia & MEDIA_SOUND) == 0) {
            return;
        }
        if (volume < 0.0f) {
        if (volume < 0.0f) {
            volume = 0.0f;
            volume = 0.0f;
        } else if (volume > 1.0f) {
        } else if (volume > 1.0f) {
+156 −4
Original line number Original line Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.mediaframeworktest.unit;


import static android.media.Ringtone.MEDIA_SOUND;
import static android.media.Ringtone.MEDIA_SOUND;
import static android.media.Ringtone.MEDIA_SOUND_AND_VIBRATION;
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.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
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.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;
@@ -293,7 +293,6 @@ public class RingtoneTest {
        ringtone.play();
        ringtone.play();


        verifyLocalPlay(mockMediaPlayer);
        verifyLocalPlay(mockMediaPlayer);
        verify(mockMediaPlayer).isLooping();  // When starting the vibration.
        verify(mMockVibrator).vibrate(VIBRATION_EFFECT, RINGTONE_VIB_ATTRIBUTES);
        verify(mMockVibrator).vibrate(VIBRATION_EFFECT, RINGTONE_VIB_ATTRIBUTES);


        // Verify dynamic controls.
        // Verify dynamic controls.
@@ -303,8 +302,7 @@ public class RingtoneTest {
        // Set looping doesn't affect an already-started vibration.
        // Set looping doesn't affect an already-started vibration.
        when(mockMediaPlayer.isLooping()).thenReturn(false);  // Checks original
        when(mockMediaPlayer.isLooping()).thenReturn(false);  // Checks original
        ringtone.setLooping(true);
        ringtone.setLooping(true);
        verify(mockMediaPlayer).isPlaying();  // Vibration check.
        verify(mockMediaPlayer).isLooping();
        verify(mockMediaPlayer, times(2)).isLooping();  // Current state, second isLooping call.
        verify(mockMediaPlayer).setLooping(true);
        verify(mockMediaPlayer).setLooping(true);


        // This is ignored because there's a vibration effect being used.
        // This is ignored because there's a vibration effect being used.
@@ -323,6 +321,160 @@ public class RingtoneTest {
        verifyNoMoreInteractions(mMockVibrator);
        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
    @Test
    public void testRingtone_localMediaWithVibrationPrefersHapticChannels() throws Exception {
    public void testRingtone_localMediaWithVibrationPrefersHapticChannels() throws Exception {
        MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();
        MediaPlayer mockMediaPlayer = mMediaPlayerRule.expectLocalMediaPlayer();