diff --git a/media/java/android/media/LocalRingtonePlayer.java b/media/java/android/media/LocalRingtonePlayer.java index 4b41a6aa8ab54..d0169b9e25016 100644 --- a/media/java/android/media/LocalRingtonePlayer.java +++ b/media/java/android/media/LocalRingtonePlayer.java @@ -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 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; + } } } diff --git a/media/java/android/media/Ringtone.java b/media/java/android/media/Ringtone.java index 2d9d411320f81..3a6b39834851c 100644 --- a/media/java/android/media/Ringtone.java +++ b/media/java/android/media/Ringtone.java @@ -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) { diff --git a/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RingtoneTest.java b/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RingtoneTest.java index f96a83a1a70c1..3c0c6847f5576 100644 --- a/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RingtoneTest.java +++ b/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RingtoneTest.java @@ -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();