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