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