AsyncRingtonePlayer.java revision 4d8e08e17d4a5206cba13cd66cdcfe4dac9dde7a
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.Build;
11import android.os.Bundle;
12import android.os.Handler;
13import android.os.HandlerThread;
14import android.os.Looper;
15import android.os.Message;
16import android.telephony.TelephonyManager;
17
18import java.io.IOException;
19import java.lang.reflect.Method;
20
21/**
22 * <p>Plays the alarm ringtone. Uses {@link Ringtone} in a separate thread so that this class can be
23 * used from the main thread. Consequently, problems controlling the ringtone do not cause ANRs in
24 * the main thread of the application.</p>
25 *
26 * <p>This class also serves a second purpose. It accomplishes alarm ringtone playback using two
27 * different mechanisms depending on the underlying platform.</p>
28 *
29 * <ul>
30 *     <li>Prior to the M platform release, ringtone playback is accomplished using
31 *     {@link MediaPlayer}. android.permission.READ_EXTERNAL_STORAGE is required to play custom
32 *     ringtones located on the SD card using this mechanism. {@link MediaPlayer} allows clients to
33 *     adjust the volume of the stream and specify that the stream should be looped.</li>
34 *
35 *     <li>Starting with the M platform release, ringtone playback is accomplished using
36 *     {@link Ringtone}. android.permission.READ_EXTERNAL_STORAGE is <strong>NOT</strong> required
37 *     to play custom ringtones located on the SD card using this mechanism. {@link Ringtone} allows
38 *     clients to adjust the volume of the stream and specify that the stream should be looped but
39 *     those methods are marked @hide in M and thus invoked using reflection. Consequently, revoking
40 *     the android.permission.READ_EXTERNAL_STORAGE permission has no effect on playback in M+.</li>
41 * </ul>
42 */
43public class AsyncRingtonePlayer {
44
45    private static final String TAG = "AsyncRingtonePlayer";
46
47    // Volume suggested by media team for in-call alarms.
48    private static final float IN_CALL_VOLUME = 0.125f;
49
50    // Message codes used with the ringtone thread.
51    private static final int EVENT_PLAY = 1;
52    private static final int EVENT_STOP = 2;
53    private static final String RINGTONE_URI_KEY = "RINGTONE_URI_KEY";
54
55    /** Handler running on the ringtone thread. */
56    private Handler mHandler;
57
58    /** {@link MediaPlayerPlaybackDelegate} on pre M; {@link RingtonePlaybackDelegate} on M+ */
59    private PlaybackDelegate mPlaybackDelegate;
60
61    /** The context. */
62    private final Context mContext;
63
64    public AsyncRingtonePlayer(Context context) {
65        mContext = context;
66    }
67
68    /** Plays the ringtone. */
69    public void play(Uri ringtoneUri) {
70        LogUtils.d(TAG, "Posting play.");
71        postMessage(EVENT_PLAY, ringtoneUri);
72    }
73
74    /** Stops playing the ringtone. */
75    public void stop() {
76        LogUtils.d(TAG, "Posting stop.");
77        postMessage(EVENT_STOP, null);
78    }
79
80    /**
81     * Posts a message to the ringtone-thread handler.
82     *
83     * @param messageCode The message to post.
84     */
85    private void postMessage(int messageCode, Uri ringtoneUri) {
86        synchronized (this) {
87            if (mHandler == null) {
88                mHandler = getNewHandler();
89            }
90
91            final Message message = mHandler.obtainMessage(messageCode);
92            if (ringtoneUri != null) {
93                final Bundle bundle = new Bundle();
94                bundle.putParcelable(RINGTONE_URI_KEY, ringtoneUri);
95                message.setData(bundle);
96            }
97            message.sendToTarget();
98        }
99    }
100
101    /**
102     * Creates a new ringtone Handler running in its own thread.
103     */
104    private Handler getNewHandler() {
105        final HandlerThread thread = new HandlerThread("ringtone-player");
106        thread.start();
107
108        return new Handler(thread.getLooper()) {
109            @Override
110            public void handleMessage(Message msg) {
111                switch (msg.what) {
112                    case EVENT_PLAY:
113                        final Uri ringtoneUri = msg.getData().getParcelable(RINGTONE_URI_KEY);
114                        getPlaybackDelegate().play(mContext, ringtoneUri);
115                        break;
116                    case EVENT_STOP:
117                        getPlaybackDelegate().stop(mContext);
118                        break;
119                }
120            }
121        };
122    }
123
124    /**
125     * @return <code>true</code> iff the device is currently in a telephone call
126     */
127    private static boolean isInTelephoneCall(Context context) {
128        final TelephonyManager tm = (TelephonyManager)
129                context.getSystemService(Context.TELEPHONY_SERVICE);
130        return tm.getCallState() != TelephonyManager.CALL_STATE_IDLE;
131    }
132
133    /**
134     * @return Uri of the ringtone to play when the user is in a telephone call
135     */
136    private static Uri getInCallRingtoneUri(Context context) {
137        final String packageName = context.getPackageName();
138        return Uri.parse("android.resource://" + packageName + "/" + R.raw.in_call_alarm);
139    }
140
141    /**
142     * @return Uri of the ringtone to play when the chosen ringtone fails to play
143     */
144    private static Uri getFallbackRingtoneUri(Context context) {
145        final String packageName = context.getPackageName();
146        return Uri.parse("android.resource://" + packageName + "/" + R.raw.fallbackring);
147    }
148
149    /**
150     * @return the platform-specific playback delegate to use to play the ringtone
151     */
152    private PlaybackDelegate getPlaybackDelegate() {
153        if (mPlaybackDelegate == null) {
154            // TODO change this to Build.VERSION.SDK_INT > Build.VERSION.LOLLIPOP_MR1
155            if (Build.VERSION.CODENAME.equals("MNC")) {
156                // Use the newer Ringtone-based playback delegate because it does not require
157                // any permissions to read from the SD card. (M+)
158                mPlaybackDelegate = new RingtonePlaybackDelegate();
159            } else {
160                // Fall back to the older MediaPlayer-based playback delegate because it is the only
161                // way to force the looping of the ringtone before M. (pre M)
162                mPlaybackDelegate = new MediaPlayerPlaybackDelegate();
163            }
164        }
165
166        return mPlaybackDelegate;
167    }
168
169    /**
170     * This interface abstracts away the differences between playing ringtones via {@link Ringtone}
171     * vs {@link MediaPlayer}.
172     */
173    private interface PlaybackDelegate {
174        void play(Context context, Uri ringtoneUri);
175        void stop(Context context);
176    }
177
178    /**
179     * Loops playback of a ringtone using {@link MediaPlayer}.
180     */
181    private static class MediaPlayerPlaybackDelegate implements PlaybackDelegate {
182
183        /** The audio focus manager. Only used by the ringtone thread. */
184        private AudioManager mAudioManager;
185
186        /** Non-{@code null} while playing a ringtone; {@code null} otherwise. */
187        private MediaPlayer mMediaPlayer;
188
189        /**
190         * Starts the actual playback of the ringtone. Executes on ringtone-thread.
191         */
192        @Override
193        public void play(final Context context, Uri ringtoneUri) {
194            if (Looper.getMainLooper() == Looper.myLooper()) {
195                LogUtils.e(TAG, "Must not be on the main thread!", new IllegalStateException());
196            }
197
198            LogUtils.i(TAG, "Play ringtone via android.media.MediaPlayer.");
199
200            if (mAudioManager == null) {
201                mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
202            }
203
204            Uri alarmNoise = ringtoneUri;
205            // Fall back to the default alarm if the database does not have an alarm stored.
206            if (alarmNoise == null) {
207                alarmNoise = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM);
208                LogUtils.v("Using default alarm: " + alarmNoise.toString());
209            }
210
211            mMediaPlayer = new MediaPlayer();
212            mMediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
213                @Override
214                public boolean onError(MediaPlayer mp, int what, int extra) {
215                    LogUtils.e("Error occurred while playing audio. Stopping AlarmKlaxon.");
216                    stop(context);
217                    return true;
218                }
219            });
220
221            try {
222                // Check if we are in a call. If we are, use the in-call alarm resource at a
223                // low volume to not disrupt the call.
224                if (isInTelephoneCall(context)) {
225                    LogUtils.v("Using the in-call alarm");
226                    mMediaPlayer.setVolume(IN_CALL_VOLUME, IN_CALL_VOLUME);
227                    alarmNoise = getInCallRingtoneUri(context);
228                }
229
230                // If alarmNoise is a custom ringtone on the sd card the app must be granted
231                // android.permission.READ_EXTERNAL_STORAGE. Pre-M this is ensured at app
232                // installation time. M+, this permission can be revoked by the user any time.
233                mMediaPlayer.setDataSource(context, alarmNoise);
234
235                startAlarm(mMediaPlayer);
236            } catch (Throwable t) {
237                LogUtils.e("Use the fallback ringtone, original was " + alarmNoise, t);
238                // The alarmNoise may be on the sd card which could be busy right now.
239                // Use the fallback ringtone.
240                try {
241                    // Must reset the media player to clear the error state.
242                    mMediaPlayer.reset();
243                    mMediaPlayer.setDataSource(context, getFallbackRingtoneUri(context));
244                    startAlarm(mMediaPlayer);
245                } catch (Throwable t2) {
246                    // At this point we just don't play anything.
247                    LogUtils.e("Failed to play fallback ringtone", t2);
248                }
249            }
250        }
251
252        /**
253         * Do the common stuff when starting the alarm.
254         */
255        private void startAlarm(MediaPlayer player) throws IOException {
256            // do not play alarms if stream volume is 0 (typically because ringer mode is silent).
257            if (mAudioManager.getStreamVolume(AudioManager.STREAM_ALARM) != 0) {
258                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
259                    player.setAudioAttributes(new AudioAttributes.Builder()
260                            .setUsage(AudioAttributes.USAGE_ALARM)
261                            .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
262                            .build());
263                }
264
265                player.setAudioStreamType(AudioManager.STREAM_ALARM);
266                player.setLooping(true);
267                player.prepare();
268                mAudioManager.requestAudioFocus(null, AudioManager.STREAM_ALARM,
269                        AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
270                player.start();
271            }
272        }
273
274        /**
275         * Stops the playback of the ringtone. Executes on the ringtone-thread.
276         */
277        @Override
278        public void stop(Context context) {
279            if (Looper.getMainLooper() == Looper.myLooper()) {
280                LogUtils.e(TAG, "Must not be on the main thread!", new IllegalStateException());
281            }
282
283            LogUtils.i(TAG, "Stop ringtone via android.media.MediaPlayer.");
284
285            // Stop audio playing
286            if (mMediaPlayer != null) {
287                mMediaPlayer.stop();
288                mAudioManager.abandonAudioFocus(null);
289                mMediaPlayer.release();
290                mMediaPlayer = null;
291            }
292        }
293    }
294
295    /**
296     * Loops playback of a ringtone using {@link Ringtone}.
297     */
298    private static class RingtonePlaybackDelegate implements PlaybackDelegate {
299
300        /** The audio focus manager. Only used by the ringtone thread. */
301        private AudioManager mAudioManager;
302
303        /** The current ringtone. Only used by the ringtone thread. */
304        private Ringtone mRingtone;
305
306        /** The method to adjust playback volume; cannot be null. */
307        private Method mSetVolumeMethod;
308
309        /** The method to adjust playback looping; cannot be null. */
310        private Method mSetLoopingMethod;
311
312        private RingtonePlaybackDelegate() {
313            try {
314                mSetVolumeMethod = Ringtone.class.getDeclaredMethod("setVolume", float.class);
315            } catch (NoSuchMethodException nsme) {
316                LogUtils.e(TAG, "Unable to locate method: Ringtone.setVolume(float).", nsme);
317            }
318
319            try {
320                mSetLoopingMethod = Ringtone.class.getDeclaredMethod("setLooping", boolean.class);
321            } catch (NoSuchMethodException nsme) {
322                LogUtils.e(TAG, "Unable to locate method: Ringtone.setLooping(boolean).", nsme);
323            }
324        }
325
326        /**
327         * Starts the actual playback of the ringtone. Executes on ringtone-thread.
328         */
329        @Override
330        public void play(Context context, Uri ringtoneUri) {
331            if (Looper.getMainLooper() == Looper.myLooper()) {
332                LogUtils.e(TAG, "Must not be on the main thread!", new IllegalStateException());
333            }
334
335            LogUtils.i(TAG, "Play ringtone via android.media.Ringtone.");
336
337            if (mAudioManager == null) {
338                mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
339            }
340
341            final boolean inTelephoneCall = isInTelephoneCall(context);
342            if (inTelephoneCall) {
343                ringtoneUri = getInCallRingtoneUri(context);
344            }
345
346            // attempt to fetch the specified ringtone
347            mRingtone = RingtoneManager.getRingtone(context, ringtoneUri);
348
349            // Attempt to enable looping the ringtone.
350            try {
351                mSetLoopingMethod.invoke(mRingtone, true);
352            } catch (Exception e) {
353                LogUtils.e(TAG, "Unable to turn looping on for android.media.Ringtone", e);
354
355                // Fall back to the default ringtone if looping could not be enabled.
356                // (Default alarm ringtone most likely has looping tags set within the .ogg file)
357                mRingtone = null;
358            }
359
360            if (mRingtone == null) {
361                // fall back to the default ringtone
362                final Uri defaultUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM);
363                mRingtone = RingtoneManager.getRingtone(context, defaultUri);
364            }
365
366            // if we don't have a ringtone at this point there isn't much recourse
367            if (mRingtone == null) {
368                LogUtils.i(TAG, "Unable to locate alarm ringtone.");
369                return;
370            }
371
372            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
373                mRingtone.setAudioAttributes(new AudioAttributes.Builder()
374                        .setUsage(AudioAttributes.USAGE_ALARM)
375                        .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
376                        .build());
377            }
378
379            // Attempt to adjust the ringtone volume if the user is in a telephone call.
380            if (inTelephoneCall) {
381                LogUtils.v("Using the in-call alarm");
382                try {
383                    mSetVolumeMethod.invoke(mRingtone, IN_CALL_VOLUME);
384                } catch (Exception e) {
385                    LogUtils.e(TAG, "Unable to set in-call volume for android.media.Ringtone", e);
386                }
387            }
388
389            mAudioManager.requestAudioFocus(null, AudioManager.STREAM_ALARM,
390                    AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
391            mRingtone.play();
392        }
393
394        /**
395         * Stops the playback of the ringtone. Executes on the ringtone-thread.
396         */
397        @Override
398        public void stop(Context context) {
399            if (Looper.getMainLooper() == Looper.myLooper()) {
400                LogUtils.e(TAG, "Must not be on the main thread!", new IllegalStateException());
401            }
402
403            LogUtils.i(TAG, "Stop ringtone via android.media.Ringtone.");
404
405            if (mRingtone != null && mRingtone.isPlaying()) {
406                LogUtils.d(TAG, "Ringtone.stop() invoked.");
407                mRingtone.stop();
408            }
409
410            if (mAudioManager != null) {
411                mAudioManager.abandonAudioFocus(null);
412            }
413        }
414    }
415}
416
417