AsyncRingtonePlayer.java revision cbb9333b9277b50866cab8d4870a3aa8f705494d
1package com.android.deskclock;
2
3import android.content.Context;
4import android.media.AudioAttributes;
5import android.media.AudioManager;
6import android.media.MediaPlayer;
7import android.media.Ringtone;
8import android.media.RingtoneManager;
9import android.net.Uri;
10import android.os.Bundle;
11import android.os.Handler;
12import android.os.HandlerThread;
13import android.os.Looper;
14import android.os.Message;
15import android.preference.PreferenceManager;
16import android.telephony.TelephonyManager;
17import android.text.format.DateUtils;
18
19import java.io.IOException;
20import java.lang.reflect.Method;
21
22/**
23 * <p>Plays the alarm ringtone. Uses {@link Ringtone} in a separate thread so that this class can be
24 * used from the main thread. Consequently, problems controlling the ringtone do not cause ANRs in
25 * the main thread of the application.</p>
26 *
27 * <p>This class also serves a second purpose. It accomplishes alarm ringtone playback using two
28 * different mechanisms depending on the underlying platform.</p>
29 *
30 * <ul>
31 *     <li>Prior to the M platform release, ringtone playback is accomplished using
32 *     {@link MediaPlayer}. android.permission.READ_EXTERNAL_STORAGE is required to play custom
33 *     ringtones located on the SD card using this mechanism. {@link MediaPlayer} allows clients to
34 *     adjust the volume of the stream and specify that the stream should be looped.</li>
35 *
36 *     <li>Starting with the M platform release, ringtone playback is accomplished using
37 *     {@link Ringtone}. android.permission.READ_EXTERNAL_STORAGE is <strong>NOT</strong> required
38 *     to play custom ringtones located on the SD card using this mechanism. {@link Ringtone} allows
39 *     clients to adjust the volume of the stream and specify that the stream should be looped but
40 *     those methods are marked @hide in M and thus invoked using reflection. Consequently, revoking
41 *     the android.permission.READ_EXTERNAL_STORAGE permission has no effect on playback in M+.</li>
42 * </ul>
43 */
44public final class AsyncRingtonePlayer {
45
46    private static final String TAG = "AsyncRingtonePlayer";
47
48    private static final String DEFAULT_CRESCENDO_LENGTH = "0";
49
50    // Volume suggested by media team for in-call alarms.
51    private static final float IN_CALL_VOLUME = 0.125f;
52
53    // Message codes used with the ringtone thread.
54    private static final int EVENT_PLAY = 1;
55    private static final int EVENT_STOP = 2;
56    private static final int EVENT_VOLUME = 3;
57    private static final String RINGTONE_URI_KEY = "RINGTONE_URI_KEY";
58
59    /** Handler running on the ringtone thread. */
60    private Handler mHandler;
61
62    /** {@link MediaPlayerPlaybackDelegate} on pre M; {@link RingtonePlaybackDelegate} on M+ */
63    private PlaybackDelegate mPlaybackDelegate;
64
65    /** The context. */
66    private final Context mContext;
67
68    /** The key of the preference that controls the crescendo behavior when playing a ringtone. */
69    private final String mCrescendoPrefKey;
70
71    /**
72     * @param crescendoPrefKey the key to the user preference that defines the crescendo behavior
73     *                         associated with this ringtone player
74     */
75    public AsyncRingtonePlayer(Context context, String crescendoPrefKey) {
76        mContext = context;
77        mCrescendoPrefKey = crescendoPrefKey;
78    }
79
80    /** Plays the ringtone. */
81    public void play(Uri ringtoneUri) {
82        LogUtils.d(TAG, "Posting play.");
83        postMessage(EVENT_PLAY, ringtoneUri, 0);
84    }
85
86    /** Stops playing the ringtone. */
87    public void stop() {
88        LogUtils.d(TAG, "Posting stop.");
89        postMessage(EVENT_STOP, null, 0);
90    }
91
92    /** Schedules an adjustment of the playback volume 50ms in the future. */
93    private void scheduleVolumeAdjustment() {
94        LogUtils.v(TAG, "Adjusting volume.");
95
96        // Ensure we never have more than one volume adjustment queued.
97        mHandler.removeMessages(EVENT_VOLUME);
98
99        // Queue the next volume adjustment.
100        postMessage(EVENT_VOLUME, null, 50);
101    }
102
103    /**
104     * Posts a message to the ringtone-thread handler.
105     *
106     * @param messageCode The message to post.
107     * @param ringtoneUri The ringtone in question, if any.
108     * @param delayMillis The amount of time to delay sending the message, if any.
109     */
110    private void postMessage(int messageCode, Uri ringtoneUri, long delayMillis) {
111        synchronized (this) {
112            if (mHandler == null) {
113                mHandler = getNewHandler();
114            }
115
116            final Message message = mHandler.obtainMessage(messageCode);
117            if (ringtoneUri != null) {
118                final Bundle bundle = new Bundle();
119                bundle.putParcelable(RINGTONE_URI_KEY, ringtoneUri);
120                message.setData(bundle);
121            }
122
123            mHandler.sendMessageDelayed(message, delayMillis);
124        }
125    }
126
127    /**
128     * Creates a new ringtone Handler running in its own thread.
129     */
130    private Handler getNewHandler() {
131        final HandlerThread thread = new HandlerThread("ringtone-player");
132        thread.start();
133
134        return new Handler(thread.getLooper()) {
135            @Override
136            public void handleMessage(Message msg) {
137                switch (msg.what) {
138                    case EVENT_PLAY:
139                        final Uri ringtoneUri = msg.getData().getParcelable(RINGTONE_URI_KEY);
140                        if (getPlaybackDelegate().play(mContext, ringtoneUri)) {
141                            scheduleVolumeAdjustment();
142                        }
143                        break;
144                    case EVENT_STOP:
145                        getPlaybackDelegate().stop(mContext);
146                        break;
147                    case EVENT_VOLUME:
148                        if (getPlaybackDelegate().adjustVolume(mContext)) {
149                            scheduleVolumeAdjustment();
150                        }
151                        break;
152                }
153            }
154        };
155    }
156
157    /**
158     * @return <code>true</code> iff the device is currently in a telephone call
159     */
160    private static boolean isInTelephoneCall(Context context) {
161        final TelephonyManager tm = (TelephonyManager)
162                context.getSystemService(Context.TELEPHONY_SERVICE);
163        return tm.getCallState() != TelephonyManager.CALL_STATE_IDLE;
164    }
165
166    /**
167     * @return Uri of the ringtone to play when the user is in a telephone call
168     */
169    private static Uri getInCallRingtoneUri(Context context) {
170        final String packageName = context.getPackageName();
171        return Uri.parse("android.resource://" + packageName + "/" + R.raw.alarm_expire);
172    }
173
174    /**
175     * @return Uri of the ringtone to play when the chosen ringtone fails to play
176     */
177    private static Uri getFallbackRingtoneUri(Context context) {
178        final String packageName = context.getPackageName();
179        return Uri.parse("android.resource://" + packageName + "/" + R.raw.alarm_expire);
180    }
181
182    /**
183     * Check if the executing thread is the one dedicated to controlling the ringtone playback.
184     */
185    private void checkAsyncRingtonePlayerThread() {
186        if (Looper.myLooper() != mHandler.getLooper()) {
187            LogUtils.e(TAG, "Must be on the AsyncRingtonePlayer thread!",
188                    new IllegalStateException());
189        }
190    }
191
192    /**
193     * @param currentTime current time of the device
194     * @param stopTime time at which the crescendo finishes
195     * @param duration length of time over which the crescendo occurs
196     * @return the scalar volume value that produces a linear increase in volume (in decibels)
197     */
198    private static float computeVolume(long currentTime, long stopTime, long duration) {
199        // Compute the percentage of the crescendo that has completed.
200        final float elapsedCrescendoTime = stopTime - currentTime;
201        final float fractionComplete = 1 - (elapsedCrescendoTime / duration);
202
203        // Use the fraction to compute a target decibel between -40dB (near silent) and 0dB (max).
204        final float gain = (fractionComplete * 40) - 40;
205
206        // Convert the target gain (in decibels) into the corresponding volume scalar.
207        final float volume = (float) Math.pow(10f, gain/20f);
208
209        LogUtils.v(TAG, "Ringtone crescendo %,.2f%% complete (scalar: %f, volume: %f dB)",
210                fractionComplete * 100, volume, gain);
211
212        return volume;
213    }
214
215    /**
216     * @return {@code true} iff the crescendo duration is more than 0 seconds
217     */
218    private boolean isCrescendoEnabled(Context context) {
219        return getCrescendoDurationMillis(context) > 0;
220    }
221
222    /**
223     * @return the duration of the crescendo in milliseconds
224     */
225    private long getCrescendoDurationMillis(Context context) {
226        final String crescendoSecondsStr = PreferenceManager.getDefaultSharedPreferences(context)
227                .getString(mCrescendoPrefKey, DEFAULT_CRESCENDO_LENGTH);
228        return Integer.parseInt(crescendoSecondsStr) * DateUtils.SECOND_IN_MILLIS;
229    }
230
231    /**
232     * @return the platform-specific playback delegate to use to play the ringtone
233     */
234    private PlaybackDelegate getPlaybackDelegate() {
235        checkAsyncRingtonePlayerThread();
236
237        if (mPlaybackDelegate == null) {
238            if (Utils.isMOrLater()) {
239                // Use the newer Ringtone-based playback delegate because it does not require
240                // any permissions to read from the SD card. (M+)
241                mPlaybackDelegate = new RingtonePlaybackDelegate();
242            } else {
243                // Fall back to the older MediaPlayer-based playback delegate because it is the only
244                // way to force the looping of the ringtone before M. (pre M)
245                mPlaybackDelegate = new MediaPlayerPlaybackDelegate();
246            }
247        }
248
249        return mPlaybackDelegate;
250    }
251
252    /**
253     * This interface abstracts away the differences between playing ringtones via {@link Ringtone}
254     * vs {@link MediaPlayer}.
255     */
256    private interface PlaybackDelegate {
257        /**
258         * @return {@code true} iff a {@link #adjustVolume volume adjustment} should be scheduled
259         */
260        boolean play(Context context, Uri ringtoneUri);
261        void stop(Context context);
262
263        /**
264         * @return {@code true} iff another volume adjustment should be scheduled
265         */
266        boolean adjustVolume(Context context);
267    }
268
269    /**
270     * Loops playback of a ringtone using {@link MediaPlayer}.
271     */
272    private class MediaPlayerPlaybackDelegate implements PlaybackDelegate {
273
274        /** The audio focus manager. Only used by the ringtone thread. */
275        private AudioManager mAudioManager;
276
277        /** Non-{@code null} while playing a ringtone; {@code null} otherwise. */
278        private MediaPlayer mMediaPlayer;
279
280        /** The duration over which to increase the volume. */
281        private long mCrescendoDuration = 0;
282
283        /** The time at which the crescendo shall cease; 0 if no crescendo is present. */
284        private long mCrescendoStopTime = 0;
285
286        /**
287         * Starts the actual playback of the ringtone. Executes on ringtone-thread.
288         */
289        @Override
290        public boolean play(final Context context, Uri ringtoneUri) {
291            checkAsyncRingtonePlayerThread();
292
293            LogUtils.i(TAG, "Play ringtone via android.media.MediaPlayer.");
294
295            if (mAudioManager == null) {
296                mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
297            }
298
299            Uri alarmNoise = ringtoneUri;
300            // Fall back to the default alarm if the database does not have an alarm stored.
301            if (alarmNoise == null) {
302                alarmNoise = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM);
303                LogUtils.v("Using default alarm: " + alarmNoise.toString());
304            }
305
306            mMediaPlayer = new MediaPlayer();
307            mMediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
308                @Override
309                public boolean onError(MediaPlayer mp, int what, int extra) {
310                    LogUtils.e("Error occurred while playing audio. Stopping AlarmKlaxon.");
311                    stop(context);
312                    return true;
313                }
314            });
315
316            boolean scheduleVolumeAdjustment = false;
317            try {
318                // Check if we are in a call. If we are, use the in-call alarm resource at a
319                // low volume to not disrupt the call.
320                if (isInTelephoneCall(context)) {
321                    LogUtils.v("Using the in-call alarm");
322                    mMediaPlayer.setVolume(IN_CALL_VOLUME, IN_CALL_VOLUME);
323                    alarmNoise = getInCallRingtoneUri(context);
324                } else if (isCrescendoEnabled(context)) {
325                    mMediaPlayer.setVolume(0, 0);
326
327                    // Compute the time at which the crescendo will stop.
328                    mCrescendoDuration = getCrescendoDurationMillis(context);
329                    mCrescendoStopTime = System.currentTimeMillis() + mCrescendoDuration;
330                    scheduleVolumeAdjustment = true;
331                }
332
333                // If alarmNoise is a custom ringtone on the sd card the app must be granted
334                // android.permission.READ_EXTERNAL_STORAGE. Pre-M this is ensured at app
335                // installation time. M+, this permission can be revoked by the user any time.
336                mMediaPlayer.setDataSource(context, alarmNoise);
337
338                startAlarm(mMediaPlayer);
339                scheduleVolumeAdjustment = true;
340            } catch (Throwable t) {
341                LogUtils.e("Use the fallback ringtone, original was " + alarmNoise, t);
342                // The alarmNoise may be on the sd card which could be busy right now.
343                // Use the fallback ringtone.
344                try {
345                    // Must reset the media player to clear the error state.
346                    mMediaPlayer.reset();
347                    mMediaPlayer.setDataSource(context, getFallbackRingtoneUri(context));
348                    startAlarm(mMediaPlayer);
349                } catch (Throwable t2) {
350                    // At this point we just don't play anything.
351                    LogUtils.e("Failed to play fallback ringtone", t2);
352                }
353            }
354
355            return scheduleVolumeAdjustment;
356        }
357
358        /**
359         * Do the common stuff when starting the alarm.
360         */
361        private void startAlarm(MediaPlayer player) throws IOException {
362            // do not play alarms if stream volume is 0 (typically because ringer mode is silent).
363            if (mAudioManager.getStreamVolume(AudioManager.STREAM_ALARM) != 0) {
364                if (Utils.isLOrLater()) {
365                    player.setAudioAttributes(new AudioAttributes.Builder()
366                            .setUsage(AudioAttributes.USAGE_ALARM)
367                            .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
368                            .build());
369                }
370
371                player.setAudioStreamType(AudioManager.STREAM_ALARM);
372                player.setLooping(true);
373                player.prepare();
374                mAudioManager.requestAudioFocus(null, AudioManager.STREAM_ALARM,
375                        AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
376                player.start();
377            }
378        }
379
380        /**
381         * Stops the playback of the ringtone. Executes on the ringtone-thread.
382         */
383        @Override
384        public void stop(Context context) {
385            checkAsyncRingtonePlayerThread();
386
387            LogUtils.i(TAG, "Stop ringtone via android.media.MediaPlayer.");
388
389            mCrescendoDuration = 0;
390            mCrescendoStopTime = 0;
391
392            // Stop audio playing
393            if (mMediaPlayer != null) {
394                mMediaPlayer.stop();
395                mMediaPlayer.release();
396                mMediaPlayer = null;
397            }
398
399            if (mAudioManager != null) {
400                mAudioManager.abandonAudioFocus(null);
401            }
402        }
403
404        /**
405         * Adjusts the volume of the ringtone being played to create a crescendo effect.
406         */
407        @Override
408        public boolean adjustVolume(Context context) {
409            checkAsyncRingtonePlayerThread();
410
411            // If media player is absent or not playing, ignore volume adjustment.
412            if (mMediaPlayer == null || !mMediaPlayer.isPlaying()) {
413                mCrescendoDuration = 0;
414                mCrescendoStopTime = 0;
415                return false;
416            }
417
418            // If the crescendo is complete set the volume to the maximum; we're done.
419            final long currentTime = System.currentTimeMillis();
420            if (currentTime > mCrescendoStopTime) {
421                mCrescendoDuration = 0;
422                mCrescendoStopTime = 0;
423                mMediaPlayer.setVolume(1, 1);
424                return false;
425            }
426
427            // The current volume of the crescendo is the percentage of the crescendo completed.
428            final float volume = computeVolume(currentTime, mCrescendoStopTime, mCrescendoDuration);
429            mMediaPlayer.setVolume(volume, volume);
430            LogUtils.i(TAG, "MediaPlayer volume set to " + volume);
431
432            // Schedule the next volume bump in the crescendo.
433            return true;
434        }
435    }
436
437    /**
438     * Loops playback of a ringtone using {@link Ringtone}.
439     */
440    private class RingtonePlaybackDelegate implements PlaybackDelegate {
441
442        /** The audio focus manager. Only used by the ringtone thread. */
443        private AudioManager mAudioManager;
444
445        /** The current ringtone. Only used by the ringtone thread. */
446        private Ringtone mRingtone;
447
448        /** The method to adjust playback volume; cannot be null. */
449        private Method mSetVolumeMethod;
450
451        /** The method to adjust playback looping; cannot be null. */
452        private Method mSetLoopingMethod;
453
454        /** The duration over which to increase the volume. */
455        private long mCrescendoDuration = 0;
456
457        /** The time at which the crescendo shall cease; 0 if no crescendo is present. */
458        private long mCrescendoStopTime = 0;
459
460        private RingtonePlaybackDelegate() {
461            try {
462                mSetVolumeMethod = Ringtone.class.getDeclaredMethod("setVolume", float.class);
463            } catch (NoSuchMethodException nsme) {
464                LogUtils.e(TAG, "Unable to locate method: Ringtone.setVolume(float).", nsme);
465            }
466
467            try {
468                mSetLoopingMethod = Ringtone.class.getDeclaredMethod("setLooping", boolean.class);
469            } catch (NoSuchMethodException nsme) {
470                LogUtils.e(TAG, "Unable to locate method: Ringtone.setLooping(boolean).", nsme);
471            }
472        }
473
474        /**
475         * Starts the actual playback of the ringtone. Executes on ringtone-thread.
476         */
477        @Override
478        public boolean play(Context context, Uri ringtoneUri) {
479            checkAsyncRingtonePlayerThread();
480
481            LogUtils.i(TAG, "Play ringtone via android.media.Ringtone.");
482
483            if (mAudioManager == null) {
484                mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
485            }
486
487            final boolean inTelephoneCall = isInTelephoneCall(context);
488            if (inTelephoneCall) {
489                ringtoneUri = getInCallRingtoneUri(context);
490            }
491
492            // attempt to fetch the specified ringtone
493            mRingtone = RingtoneManager.getRingtone(context, ringtoneUri);
494
495            if (mRingtone == null) {
496                // fall back to the default ringtone
497                final Uri defaultUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM);
498                mRingtone = RingtoneManager.getRingtone(context, defaultUri);
499            }
500
501            // Attempt to enable looping the ringtone.
502            try {
503                mSetLoopingMethod.invoke(mRingtone, true);
504            } catch (Exception e) {
505                LogUtils.e(TAG, "Unable to turn looping on for android.media.Ringtone", e);
506
507                // Fall back to the default ringtone if looping could not be enabled.
508                // (Default alarm ringtone most likely has looping tags set within the .ogg file)
509                mRingtone = null;
510            }
511
512            // if we don't have a ringtone at this point there isn't much recourse
513            if (mRingtone == null) {
514                LogUtils.i(TAG, "Unable to locate alarm ringtone, using internal fallback " +
515                        "ringtone.");
516                mRingtone = RingtoneManager.getRingtone(context, getFallbackRingtoneUri(context));
517            }
518
519            if (Utils.isLOrLater()) {
520                mRingtone.setAudioAttributes(new AudioAttributes.Builder()
521                        .setUsage(AudioAttributes.USAGE_ALARM)
522                        .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
523                        .build());
524            }
525
526            // Attempt to adjust the ringtone volume if the user is in a telephone call.
527            boolean scheduleVolumeAdjustment = false;
528            if (inTelephoneCall) {
529                LogUtils.v("Using the in-call alarm");
530                setRingtoneVolume(IN_CALL_VOLUME);
531            } else if (isCrescendoEnabled(context)) {
532                setRingtoneVolume(0);
533
534                // Compute the time at which the crescendo will stop.
535                mCrescendoDuration = getCrescendoDurationMillis(context);
536                mCrescendoStopTime = System.currentTimeMillis() + mCrescendoDuration;
537                scheduleVolumeAdjustment = true;
538            }
539
540            mAudioManager.requestAudioFocus(null, AudioManager.STREAM_ALARM,
541                    AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
542            mRingtone.play();
543
544            return scheduleVolumeAdjustment;
545        }
546
547        /**
548         * Sets the volume of the ringtone.
549         *
550         * @param volume a raw scalar in range 0.0 to 1.0, where 0.0 mutes this player, and 1.0
551         *               corresponds to no attenuation being applied.
552         */
553        private void setRingtoneVolume(float volume) {
554            try {
555                mSetVolumeMethod.invoke(mRingtone, volume);
556            } catch (Exception e) {
557                LogUtils.e(TAG, "Unable to set volume for android.media.Ringtone", e);
558            }
559        }
560
561        /**
562         * Stops the playback of the ringtone. Executes on the ringtone-thread.
563         */
564        @Override
565        public void stop(Context context) {
566            checkAsyncRingtonePlayerThread();
567
568            LogUtils.i(TAG, "Stop ringtone via android.media.Ringtone.");
569
570            mCrescendoDuration = 0;
571            mCrescendoStopTime = 0;
572
573            if (mRingtone != null && mRingtone.isPlaying()) {
574                LogUtils.d(TAG, "Ringtone.stop() invoked.");
575                mRingtone.stop();
576            }
577
578            mRingtone = null;
579
580            if (mAudioManager != null) {
581                mAudioManager.abandonAudioFocus(null);
582            }
583        }
584
585        /**
586         * Adjusts the volume of the ringtone being played to create a crescendo effect.
587         */
588        @Override
589        public boolean adjustVolume(Context context) {
590            checkAsyncRingtonePlayerThread();
591
592            // If ringtone is absent or not playing, ignore volume adjustment.
593            if (mRingtone == null || !mRingtone.isPlaying()) {
594                mCrescendoDuration = 0;
595                mCrescendoStopTime = 0;
596                return false;
597            }
598
599            // If the crescendo is complete set the volume to the maximum; we're done.
600            final long currentTime = System.currentTimeMillis();
601            if (currentTime > mCrescendoStopTime) {
602                mCrescendoDuration = 0;
603                mCrescendoStopTime = 0;
604                setRingtoneVolume(1);
605                return false;
606            }
607
608            final float volume = computeVolume(currentTime, mCrescendoStopTime, mCrescendoDuration);
609            setRingtoneVolume(volume);
610
611            // Schedule the next volume bump in the crescendo.
612            return true;
613        }
614    }
615}
616
617