blob: fe7cc3ec2af3c74a913b7bd1369250969ff9aba6 [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.media;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
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;
import java.util.ArrayList;
import java.util.Objects;
/**
* Plays a ringtone on the local process.
* @hide
*/
public class LocalRingtonePlayer
implements RingtoneV2.RingtonePlayer, MediaPlayer.OnCompletionListener {
private static final String TAG = "LocalRingtonePlayer";
// keep references on active Ringtones until stopped or completion listener called.
private static final ArrayList<LocalRingtonePlayer> sActiveMediaPlayers = new ArrayList<>();
private final MediaPlayer mMediaPlayer;
private final AudioAttributes mAudioAttributes;
private final RingtoneV2.RingtonePlayer mVibrationPlayer;
private final Ringtone.Injectables mInjectables;
private final AudioManager mAudioManager;
private final VolumeShaper mVolumeShaper;
private HapticGenerator mHapticGenerator;
private LocalRingtonePlayer(@NonNull MediaPlayer mediaPlayer,
@NonNull AudioAttributes audioAttributes, @NonNull Ringtone.Injectables injectables,
@NonNull AudioManager audioManager, @Nullable HapticGenerator hapticGenerator,
@Nullable VolumeShaper volumeShaper,
@Nullable RingtoneV2.RingtonePlayer vibrationPlayer) {
Objects.requireNonNull(mediaPlayer);
Objects.requireNonNull(audioAttributes);
Objects.requireNonNull(injectables);
Objects.requireNonNull(audioManager);
mMediaPlayer = mediaPlayer;
mAudioAttributes = audioAttributes;
mInjectables = injectables;
mAudioManager = audioManager;
mVolumeShaper = volumeShaper;
mVibrationPlayer = vibrationPlayer;
mHapticGenerator = hapticGenerator;
}
/**
* Creates a {@link LocalRingtonePlayer} for a Uri, returning null if the Uri can't be
* loaded in the local player.
*/
@Nullable
static RingtoneV2.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,
@Nullable AudioDeviceInfo preferredDevice, boolean initialHapticGeneratorEnabled,
boolean initialLooping, float initialVolume) {
Objects.requireNonNull(context);
Objects.requireNonNull(soundUri);
Objects.requireNonNull(audioAttributes);
Trace.beginSection("createLocalMediaPlayer");
MediaPlayer mediaPlayer = injectables.newMediaPlayer();
HapticGenerator hapticGenerator = null;
try {
mediaPlayer.setDataSource(context, soundUri);
mediaPlayer.setAudioAttributes(audioAttributes);
mediaPlayer.setPreferredDevice(preferredDevice);
mediaPlayer.setLooping(initialLooping);
mediaPlayer.setVolume(isVibrationOnly ? 0 : initialVolume);
if (initialHapticGeneratorEnabled) {
hapticGenerator = injectables.createHapticGenerator(mediaPlayer);
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;
}
}
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, vibrationEffectPlayer);
} catch (SecurityException | IOException e) {
if (hapticGenerator != null) {
hapticGenerator.release();
}
// volume shaper closes with media player
mediaPlayer.release();
return null;
} finally {
Trace.endSection();
}
}
/**
* Creates a {@link LocalRingtonePlayer} for an externally referenced file descriptor. This is
* intended for loading a fallback from an internal resource, rather than via a Uri.
*/
@Nullable
static LocalRingtonePlayer createForFallback(
@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,
boolean initialLooping, float initialVolume) {
// Haptic generator not supported for fallback.
Objects.requireNonNull(audioManager);
Objects.requireNonNull(afd);
Objects.requireNonNull(audioAttributes);
Trace.beginSection("createFallbackLocalMediaPlayer");
MediaPlayer mediaPlayer = injectables.newMediaPlayer();
try {
if (afd.getDeclaredLength() < 0) {
mediaPlayer.setDataSource(afd.getFileDescriptor());
} else {
mediaPlayer.setDataSource(afd.getFileDescriptor(),
afd.getStartOffset(),
afd.getDeclaredLength());
}
mediaPlayer.setAudioAttributes(audioAttributes);
mediaPlayer.setPreferredDevice(preferredDevice);
mediaPlayer.setLooping(initialLooping);
mediaPlayer.setVolume(initialVolume);
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;
}
}
VibrationEffectPlayer vibrationEffectPlayer = (vibrationEffect == null) ? null :
new VibrationEffectPlayer(
vibrationEffect, audioAttributes, vibrator, initialLooping);
return new LocalRingtonePlayer(mediaPlayer, audioAttributes, injectables, audioManager,
/* hapticGenerator= */ null, volumeShaper, vibrationEffectPlayer);
} catch (SecurityException | IOException e) {
Log.e(TAG, "Failed to open fallback ringtone");
// TODO: vibration-effect-only / no-sound LocalRingtonePlayer.
mediaPlayer.release();
return null;
} finally {
Trace.endSection();
}
}
@Override
public boolean play() {
// Play ringtones if stream volume is over 0 or if it is a haptic-only ringtone
// (typically because ringer mode is vibrate).
if (mAudioManager.getStreamVolume(AudioAttributes.toLegacyStreamType(mAudioAttributes))
== 0 && (mAudioAttributes.areHapticChannelsMuted() || !hasHapticChannels())) {
maybeStartVibration();
return true; // Successfully played while muted.
}
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);
mMediaPlayer.start();
if (mVolumeShaper != null) {
mVolumeShaper.apply(VolumeShaper.Operation.PLAY);
}
maybeStartVibration();
return true;
}
private void maybeStartVibration() {
if (mVibrationPlayer != null) {
mVibrationPlayer.play();
}
}
@Override
public boolean isPlaying() {
return mMediaPlayer.isPlaying();
}
@Override
public void stopAndRelease() {
synchronized (sActiveMediaPlayers) {
sActiveMediaPlayers.remove(this);
}
try {
mMediaPlayer.stop();
} finally {
if (mVibrationPlayer != null) {
try {
mVibrationPlayer.stopAndRelease();
} catch (Exception e) {
Log.e(TAG, "Exception stopping ringtone vibration", e);
}
}
if (mHapticGenerator != null) {
mHapticGenerator.release();
}
mMediaPlayer.setOnCompletionListener(null);
mMediaPlayer.reset();
mMediaPlayer.release();
}
}
@Override
public void setPreferredDevice(@Nullable AudioDeviceInfo audioDeviceInfo) {
mMediaPlayer.setPreferredDevice(audioDeviceInfo);
}
@Override
public void setLooping(boolean looping) {
boolean wasLooping = mMediaPlayer.isLooping();
if (wasLooping == looping) {
return;
}
mMediaPlayer.setLooping(looping);
if (mVibrationPlayer != null) {
mVibrationPlayer.setLooping(looping);
}
}
@Override
public void setHapticGeneratorEnabled(boolean enabled) {
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 && !hasHapticChannels()) {
mHapticGenerator = mInjectables.createHapticGenerator(mMediaPlayer);
}
if (mHapticGenerator != null) {
mHapticGenerator.setEnabled(enabled);
}
}
@Override
public void setVolume(float volume) {
mMediaPlayer.setVolume(volume);
// no effect on vibration player
}
@Override
public boolean hasHapticChannels() {
return mInjectables.hasHapticChannels(mMediaPlayer);
}
@Override
public void onCompletion(MediaPlayer mp) {
synchronized (sActiveMediaPlayers) {
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 RingtoneV2.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;
}
}
}