1/*
2 * Copyright (C) 2014 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.fmradio;
18
19import android.content.ContentResolver;
20import android.content.ContentUris;
21import android.content.ContentValues;
22import android.content.Context;
23import android.database.Cursor;
24import android.media.MediaPlayer;
25import android.media.MediaRecorder;
26import android.media.MediaScannerConnection;
27import android.net.Uri;
28import android.os.Environment;
29import android.os.SystemClock;
30import android.provider.MediaStore;
31import android.text.format.DateFormat;
32import android.util.Log;
33
34import java.io.File;
35import java.io.IOException;
36import java.text.SimpleDateFormat;
37import java.util.Date;
38import java.util.Locale;
39
40/**
41 * This class provider interface to recording, stop recording, save recording
42 * file, play recording file
43 */
44public class FmRecorder implements MediaRecorder.OnErrorListener, MediaRecorder.OnInfoListener {
45    private static final String TAG = "FmRecorder";
46    // file prefix
47    public static final String RECORDING_FILE_PREFIX = "FM";
48    // file extension
49    public static final String RECORDING_FILE_EXTENSION = ".3gpp";
50    // recording file folder
51    public static final String FM_RECORD_FOLDER = "FM Recording";
52    private static final String RECORDING_FILE_TYPE = "audio/3gpp";
53    private static final String RECORDING_FILE_SOURCE = "FM Recordings";
54    // error type no sdcard
55    public static final int ERROR_SDCARD_NOT_PRESENT = 0;
56    // error type sdcard not have enough space
57    public static final int ERROR_SDCARD_INSUFFICIENT_SPACE = 1;
58    // error type can't write sdcard
59    public static final int ERROR_SDCARD_WRITE_FAILED = 2;
60    // error type recorder internal error occur
61    public static final int ERROR_RECORDER_INTERNAL = 3;
62
63    // FM Recorder state not recording and not playing
64    public static final int STATE_IDLE = 5;
65    // FM Recorder state recording
66    public static final int STATE_RECORDING = 6;
67    // FM Recorder state playing
68    public static final int STATE_PLAYBACK = 7;
69    // FM Recorder state invalid, need to check
70    public static final int STATE_INVALID = -1;
71
72    // use to record current FM recorder state
73    public int mInternalState = STATE_IDLE;
74    // the recording time after start recording
75    private long mRecordTime = 0;
76    // record start time
77    private long mRecordStartTime = 0;
78    // current record file
79    private File mRecordFile = null;
80    // record current record file is saved by user
81    private boolean mIsRecordingFileSaved = false;
82    // listener use for notify service the record state or error state
83    private OnRecorderStateChangedListener mStateListener = null;
84    // recorder use for record file
85    private MediaRecorder mRecorder = null;
86
87    /**
88     * Start recording the voice of FM, also check the pre-conditions, if not
89     * meet, will return an error message to the caller. if can start recording
90     * success, will set FM record state to recording and notify to the caller
91     */
92    public void startRecording(Context context) {
93        mRecordTime = 0;
94
95        // Check external storage
96        if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
97            Log.e(TAG, "startRecording, no external storage available");
98            setError(ERROR_SDCARD_NOT_PRESENT);
99            return;
100        }
101
102        String recordingSdcard = FmUtils.getDefaultStoragePath();
103        // check whether have sufficient storage space, if not will notify
104        // caller error message
105        if (!FmUtils.hasEnoughSpace(recordingSdcard)) {
106            setError(ERROR_SDCARD_INSUFFICIENT_SPACE);
107            Log.e(TAG, "startRecording, SD card does not have sufficient space!!");
108            return;
109        }
110
111        // get external storage directory
112        File sdDir = new File(recordingSdcard);
113        File recordingDir = new File(sdDir, FM_RECORD_FOLDER);
114        // exist a file named FM Recording, so can't create FM recording folder
115        if (recordingDir.exists() && !recordingDir.isDirectory()) {
116            Log.e(TAG, "startRecording, a file with name \"FM Recording\" already exists!!");
117            setError(ERROR_SDCARD_WRITE_FAILED);
118            return;
119        } else if (!recordingDir.exists()) { // try to create recording folder
120            boolean mkdirResult = recordingDir.mkdir();
121            if (!mkdirResult) { // create recording file failed
122                setError(ERROR_RECORDER_INTERNAL);
123                return;
124            }
125        }
126        // create recording temporary file
127        long curTime = System.currentTimeMillis();
128        Date date = new Date(curTime);
129        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MMddyyyy_HHmmss",
130                Locale.ENGLISH);
131        String time = simpleDateFormat.format(date);
132        StringBuilder stringBuilder = new StringBuilder();
133        stringBuilder.append(time).append(RECORDING_FILE_EXTENSION);
134        String name = stringBuilder.toString();
135        mRecordFile = new File(recordingDir, name);
136        try {
137            if (mRecordFile.createNewFile()) {
138                Log.d(TAG, "startRecording, createNewFile success with path "
139                        + mRecordFile.getPath());
140            }
141        } catch (IOException e) {
142            Log.e(TAG, "startRecording, IOException while createTempFile: " + e);
143            e.printStackTrace();
144            setError(ERROR_SDCARD_WRITE_FAILED);
145            return;
146        }
147        // set record parameter and start recording
148        try {
149            mRecorder = new MediaRecorder();
150            mRecorder.setOnErrorListener(this);
151            mRecorder.setOnInfoListener(this);
152            mRecorder.setAudioSource(MediaRecorder.AudioSource.FM_TUNER);
153            mRecorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP);
154            mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
155            final int samplingRate = 44100;
156            mRecorder.setAudioSamplingRate(samplingRate);
157            final int bitRate = 128000;
158            mRecorder.setAudioEncodingBitRate(bitRate);
159            final int audiochannels = 2;
160            mRecorder.setAudioChannels(audiochannels);
161            mRecorder.setOutputFile(mRecordFile.getAbsolutePath());
162            mRecorder.prepare();
163            mRecordStartTime = SystemClock.elapsedRealtime();
164            mRecorder.start();
165            mIsRecordingFileSaved = false;
166        } catch (IllegalStateException e) {
167            Log.e(TAG, "startRecording, IllegalStateException while starting recording!", e);
168            setError(ERROR_RECORDER_INTERNAL);
169            return;
170        } catch (IOException e) {
171            Log.e(TAG, "startRecording, IOException while starting recording!", e);
172            setError(ERROR_RECORDER_INTERNAL);
173            return;
174        }
175        setState(STATE_RECORDING);
176    }
177
178    /**
179     * Stop recording, compute recording time and update FM recorder state
180     */
181    public void stopRecording() {
182        if (STATE_RECORDING != mInternalState) {
183            Log.w(TAG, "stopRecording, called in wrong state!!");
184            return;
185        }
186
187        mRecordTime = SystemClock.elapsedRealtime() - mRecordStartTime;
188        stopRecorder();
189        setState(STATE_IDLE);
190    }
191
192    /**
193     * Compute the current record time
194     *
195     * @return The current record time
196     */
197    public long getRecordTime() {
198        if (STATE_RECORDING == mInternalState) {
199            mRecordTime = SystemClock.elapsedRealtime() - mRecordStartTime;
200        }
201        return mRecordTime;
202    }
203
204    /**
205     * Get FM recorder current state
206     *
207     * @return FM recorder current state
208     */
209    public int getState() {
210        return mInternalState;
211    }
212
213    /**
214     * Get current record file name
215     *
216     * @return The current record file name
217     */
218    public String getRecordFileName() {
219        if (mRecordFile != null) {
220            String fileName = mRecordFile.getName();
221            int index = fileName.indexOf(RECORDING_FILE_EXTENSION);
222            if (index > 0) {
223                fileName = fileName.substring(0, index);
224            }
225            return fileName;
226        }
227        return null;
228    }
229
230    /**
231     * Save recording file with the given name, and insert it's info to database
232     *
233     * @param context The context
234     * @param newName The name to override default recording name
235     */
236    public void saveRecording(Context context, String newName) {
237        if (mRecordFile == null) {
238            Log.e(TAG, "saveRecording, recording file is null!");
239            return;
240        }
241
242        File newRecordFile = new File(mRecordFile.getParent(), newName + RECORDING_FILE_EXTENSION);
243        boolean succuss = mRecordFile.renameTo(newRecordFile);
244        if (succuss) {
245            mRecordFile = newRecordFile;
246        }
247        mIsRecordingFileSaved = true;
248        // insert recording file info to database
249        addRecordingToDatabase(context);
250    }
251
252    /**
253     * Discard current recording file, release recorder and player
254     */
255    public void discardRecording() {
256        if ((STATE_RECORDING == mInternalState) && (null != mRecorder)) {
257            stopRecorder();
258        }
259
260        if (mRecordFile != null && !mIsRecordingFileSaved) {
261            if (!mRecordFile.delete()) {
262                // deletion failed, possibly due to hot plug out SD card
263                Log.d(TAG, "discardRecording, delete file failed!");
264            }
265            mRecordFile = null;
266            mRecordStartTime = 0;
267            mRecordTime = 0;
268        }
269        setState(STATE_IDLE);
270    }
271
272    /**
273     * Set the callback use to notify FM recorder state and error message
274     *
275     * @param listener the callback
276     */
277    public void registerRecorderStateListener(OnRecorderStateChangedListener listener) {
278        mStateListener = listener;
279    }
280
281    /**
282     * Interface to notify FM recorder state and error message
283     */
284    public interface OnRecorderStateChangedListener {
285        /**
286         * notify FM recorder state
287         *
288         * @param state current FM recorder state
289         */
290        void onRecorderStateChanged(int state);
291
292        /**
293         * notify FM recorder error message
294         *
295         * @param error error type
296         */
297        void onRecorderError(int error);
298    }
299
300    /**
301     * When recorder occur error, release player, notify error message, and
302     * update FM recorder state to idle
303     *
304     * @param mr The current recorder
305     * @param what The error message type
306     * @param extra The error message extra
307     */
308    @Override
309    public void onError(MediaRecorder mr, int what, int extra) {
310        Log.e(TAG, "onError, what = " + what + ", extra = " + extra);
311        stopRecorder();
312        setError(ERROR_RECORDER_INTERNAL);
313        if (STATE_RECORDING == mInternalState) {
314            setState(STATE_IDLE);
315        }
316    }
317
318    @Override
319    public void onInfo(MediaRecorder mr, int what, int extra) {
320        Log.d(TAG, "onInfo: what=" + what + ", extra=" + extra);
321        if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED ||
322            what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
323            onError(mr, what, extra);
324        }
325    }
326
327    /**
328     * Reset FM recorder
329     */
330    public void resetRecorder() {
331        if (mRecorder != null) {
332            mRecorder.release();
333            mRecorder = null;
334        }
335        mRecordFile = null;
336        mRecordStartTime = 0;
337        mRecordTime = 0;
338        mInternalState = STATE_IDLE;
339    }
340
341    /**
342     * Notify error message to the callback
343     *
344     * @param error FM recorder error type
345     */
346    private void setError(int error) {
347        if (mStateListener != null) {
348            mStateListener.onRecorderError(error);
349        }
350    }
351
352    /**
353     * Notify FM recorder state message to the callback
354     *
355     * @param state FM recorder current state
356     */
357    private void setState(int state) {
358        mInternalState = state;
359        if (mStateListener != null) {
360            mStateListener.onRecorderStateChanged(state);
361        }
362    }
363
364    /**
365     * Save recording file info to database
366     *
367     * @param context The context
368     */
369    private void addRecordingToDatabase(final Context context) {
370        long curTime = System.currentTimeMillis();
371        long modDate = mRecordFile.lastModified();
372        Date date = new Date(curTime);
373
374        java.text.DateFormat dateFormatter = DateFormat.getDateFormat(context);
375        java.text.DateFormat timeFormatter = DateFormat.getTimeFormat(context);
376        String title = getRecordFileName();
377        StringBuilder stringBuilder = new StringBuilder()
378                .append(FM_RECORD_FOLDER)
379                .append(" ")
380                .append(dateFormatter.format(date))
381                .append(" ")
382                .append(timeFormatter.format(date));
383        String artist = stringBuilder.toString();
384
385        final int size = 9;
386        ContentValues cv = new ContentValues(size);
387        cv.put(MediaStore.Audio.Media.IS_MUSIC, 1);
388        cv.put(MediaStore.Audio.Media.TITLE, title);
389        cv.put(MediaStore.Audio.Media.DATA, mRecordFile.getAbsolutePath());
390        final int oneSecond = 1000;
391        cv.put(MediaStore.Audio.Media.DATE_ADDED, (int) (curTime / oneSecond));
392        cv.put(MediaStore.Audio.Media.DATE_MODIFIED, (int) (modDate / oneSecond));
393        cv.put(MediaStore.Audio.Media.MIME_TYPE, RECORDING_FILE_TYPE);
394        cv.put(MediaStore.Audio.Media.ARTIST, artist);
395        cv.put(MediaStore.Audio.Media.ALBUM, RECORDING_FILE_SOURCE);
396        cv.put(MediaStore.Audio.Media.DURATION, mRecordTime);
397
398        int recordingId = addToAudioTable(context, cv);
399        if (recordingId < 0) {
400            // insert failed
401            return;
402        }
403        int playlistId = getPlaylistId(context);
404        if (playlistId < 0) {
405            // play list not exist, create FM Recording play list
406            playlistId = createPlaylist(context);
407        }
408        if (playlistId < 0) {
409            // insert playlist failed
410            return;
411        }
412        // insert item to FM recording play list
413        addToPlaylist(context, playlistId, recordingId);
414        // scan to update duration
415        MediaScannerConnection.scanFile(context, new String[] { mRecordFile.getPath() },
416                null, null);
417    }
418
419    /**
420     * Get the play list ID
421     * @param context Current passed in Context instance
422     * @return The play list ID
423     */
424    public static int getPlaylistId(final Context context) {
425        Cursor playlistCursor = context.getContentResolver().query(
426                MediaStore.Audio.Playlists.getContentUri("external"),
427                new String[] {
428                    MediaStore.Audio.Playlists._ID
429                },
430                MediaStore.Audio.Playlists.DATA + "=?",
431                new String[] {
432                    FmUtils.getPlaylistPath(context) + RECORDING_FILE_SOURCE
433                },
434                null);
435        int playlistId = -1;
436        if (null != playlistCursor) {
437            try {
438                if (playlistCursor.moveToFirst()) {
439                    playlistId = playlistCursor.getInt(0);
440                }
441            } finally {
442                playlistCursor.close();
443            }
444        }
445        return playlistId;
446    }
447
448    private int createPlaylist(final Context context) {
449        final int size = 1;
450        ContentValues cv = new ContentValues(size);
451        cv.put(MediaStore.Audio.Playlists.NAME, RECORDING_FILE_SOURCE);
452        Uri newPlaylistUri = context.getContentResolver().insert(
453                MediaStore.Audio.Playlists.getContentUri("external"), cv);
454        if (newPlaylistUri == null) {
455            Log.d(TAG, "createPlaylist, create playlist failed");
456            return -1;
457        }
458        return Integer.valueOf(newPlaylistUri.getLastPathSegment());
459    }
460
461    private int addToAudioTable(final Context context, final ContentValues cv) {
462        ContentResolver resolver = context.getContentResolver();
463        int id = -1;
464
465        Cursor cursor = null;
466
467        try {
468            cursor = resolver.query(
469                    MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
470                    new String[] { MediaStore.Audio.Media._ID },
471                    MediaStore.Audio.Media.DATA + "=?",
472                    new String[] { mRecordFile.getPath() },
473                    null);
474            if (cursor != null && cursor.moveToFirst()) {
475                // Exist in database, just update it
476                id = cursor.getInt(0);
477                resolver.update(ContentUris.withAppendedId(
478                        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id),
479                        cv,
480                        null,
481                        null);
482            } else {
483                // insert new entry to database
484                Uri uri = context.getContentResolver().insert(
485                        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, cv);
486                if (uri != null) {
487                    id = Integer.valueOf(uri.getLastPathSegment());
488                }
489            }
490        } finally {
491            if (cursor != null) {
492                cursor.close();
493            }
494        }
495        return id;
496    }
497
498    private void addToPlaylist(final Context context, final int playlistId, final int recordingId) {
499        ContentResolver resolver = context.getContentResolver();
500        Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId);
501        int order = 0;
502        Cursor cursor = null;
503        try {
504            cursor = resolver.query(
505                    MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
506                    new String[] { MediaStore.Audio.Media._ID },
507                    MediaStore.Audio.Media.DATA + "=?",
508                    new String[] { mRecordFile.getPath() },
509                    null);
510            if (cursor != null && cursor.moveToFirst()) {
511                // Exist in database, just update it
512                order = cursor.getCount();
513            }
514        } finally {
515            if (cursor != null) {
516                cursor.close();
517            }
518        }
519        ContentValues cv = new ContentValues(2);
520        cv.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, recordingId);
521        cv.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, order);
522        context.getContentResolver().insert(uri, cv);
523    }
524
525    private void stopRecorder() {
526        synchronized (this) {
527            if (mRecorder != null) {
528                try {
529                    mRecorder.stop();
530                } catch (IllegalStateException ex) {
531                    Log.e(TAG, "stopRecorder, IllegalStateException ocurr " + ex);
532                    setError(ERROR_RECORDER_INTERNAL);
533                } finally {
534                    mRecorder.release();
535                    mRecorder = null;
536                }
537            }
538        }
539    }
540}
541