From 108385e178ebe292e1625e443deed62f27b4887d Mon Sep 17 00:00:00 2001 From: Simon Bowden Date: Sat, 11 Mar 2023 22:57:36 +0000 Subject: [PATCH] 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 --- core/java/android/os/VibrationEffect.java | 44 ++- media/java/android/media/IRingtonePlayer.aidl | 4 +- .../android/media/LocalRingtonePlayer.java | 148 ++++++-- media/java/android/media/Ringtone.java | 254 ++++++++++++-- media/java/android/media/RingtoneManager.java | 1 + .../mediaframeworktest/unit/RingtoneTest.java | 330 ++++++++++++++++-- .../systemui/media/RingtonePlayer.java | 66 ++-- 7 files changed, 710 insertions(+), 137 deletions(-) diff --git a/core/java/android/os/VibrationEffect.java b/core/java/android/os/VibrationEffect.java index 8edb8213248d3..af3fc15e3f105 100644 --- a/core/java/android/os/VibrationEffect.java +++ b/core/java/android/os/VibrationEffect.java @@ -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; *

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; } diff --git a/media/java/android/media/IRingtonePlayer.aidl b/media/java/android/media/IRingtonePlayer.aidl index 97cc1a8d4a545..b3f72a1aa2bb2 100644 --- a/media/java/android/media/IRingtonePlayer.aidl +++ b/media/java/android/media/IRingtonePlayer.aidl @@ -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); diff --git a/media/java/android/media/LocalRingtonePlayer.java b/media/java/android/media/LocalRingtonePlayer.java index 4aa24af9d444e..4b41a6aa8ab54 100644 --- a/media/java/android/media/LocalRingtonePlayer.java +++ b/media/java/android/media/LocalRingtonePlayer.java @@ -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 sActiveRingtones = new ArrayList<>(); + private static final ArrayList 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. } diff --git a/media/java/android/media/Ringtone.java b/media/java/android/media/Ringtone.java index 4be88fb23badd..2d9d411320f81 100644 --- a/media/java/android/media/Ringtone.java +++ b/media/java/android/media/Ringtone.java @@ -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. + * + *

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. + * + *

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). + * + *

Without this setting, the haptic channels will be automatically muted or not by the + * Ringtone according to whether vibration is enabled or not. + * + *

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; + } + } } diff --git a/media/java/android/media/RingtoneManager.java b/media/java/android/media/RingtoneManager.java index ebf02a26672b1..12766fbb6c9da 100644 --- a/media/java/android/media/RingtoneManager.java +++ b/media/java/android/media/RingtoneManager.java @@ -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(); } 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 d8e3f8d37c096..f96a83a1a70c1 100644 --- a/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RingtoneTest.java +++ b/media/tests/MediaFrameworkTest/src/com/android/mediaframeworktest/unit/RingtoneTest.java @@ -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 mIBinderCaptor; - @Mock IRingtonePlayer mMockRemotePlayer; + @Captor private ArgumentCaptor 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 mMockHapticGeneratorMap = new ArrayMap<>(); + // Media players with haptic channels. + private ArraySet 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; } } } diff --git a/packages/SystemUI/src/com/android/systemui/media/RingtonePlayer.java b/packages/SystemUI/src/com/android/systemui/media/RingtonePlayer.java index d4b30d3913ef1..6d9844d9ec595 100644 --- a/packages/SystemUI/src/com/android/systemui/media/RingtonePlayer.java +++ b/packages/SystemUI/src/com/android/systemui/media/RingtonePlayer.java @@ -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(); }