// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2009-2011 Google, All Rights reserved
// Copyright 2011-2012 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime;
import com.google.appinventor.components.annotations.DesignerComponent;
import com.google.appinventor.components.annotations.DesignerProperty;
import com.google.appinventor.components.annotations.PropertyCategory;
import com.google.appinventor.components.annotations.SimpleEvent;
import com.google.appinventor.components.annotations.SimpleFunction;
import com.google.appinventor.components.annotations.SimpleObject;
import com.google.appinventor.components.annotations.SimpleProperty;
import com.google.appinventor.components.annotations.UsesPermissions;
import com.google.appinventor.components.common.ComponentCategory;
import com.google.appinventor.components.common.PropertyTypeConstants;
import com.google.appinventor.components.common.YaVersion;
import com.google.appinventor.components.runtime.util.ErrorMessages;
import com.google.appinventor.components.runtime.util.FileUtil;
import android.media.MediaRecorder;
import android.media.MediaRecorder.OnErrorListener;
import android.media.MediaRecorder.OnInfoListener;
import android.os.Environment;
import android.util.Log;
import java.io.IOException;
/**
* Multimedia component that records audio using
* {@link android.media.MediaRecorder}.
*
*/
@DesignerComponent(version = YaVersion.SOUND_RECORDER_COMPONENT_VERSION,
description = "<p>Multimedia component that records audio.</p>",
category = ComponentCategory.MEDIA,
nonVisible = true,
iconName = "images/soundRecorder.png")
@SimpleObject
@UsesPermissions(permissionNames = "android.permission.RECORD_AUDIO," +
"android.permission.WRITE_EXTERNAL_STORAGE," +
"android.permission.READ_EXTERNAL_STORAGE")
public final class SoundRecorder extends AndroidNonvisibleComponent
implements Component, OnErrorListener, OnInfoListener {
private static final String TAG = "SoundRecorder";
// the path to the savedRecording
// if it is the null string, the recorder will generate a path
// note that this is also initialized to "" in the designer
private String savedRecording = "";
/**
* This class encapsulates the required state during recording.
*/
private class RecordingController {
final MediaRecorder recorder;
// file is the same as savedRecording, but we'll keep it local to the
// RecordingController for future flexibility
final String file;
RecordingController(String savedRecording) throws IOException {
// pick a pathname if none was specified
file = (savedRecording.equals("")) ?
FileUtil.getRecordingFile("3gp").getAbsolutePath() :
savedRecording;
recorder = new MediaRecorder();
recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP);
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);
Log.i(TAG, "Setting output file to " + file);
recorder.setOutputFile(file);
Log.i(TAG, "preparing");
recorder.prepare();
recorder.setOnErrorListener(SoundRecorder.this);
recorder.setOnInfoListener(SoundRecorder.this);
}
void start() throws IllegalStateException {
Log.i(TAG, "starting");
try {
recorder.start();
} catch (IllegalStateException e) {
// This is the error produced when there are two recorders running.
// There might be other causes, but we don't know them.
// Using Log.e will log a stack trace, so we can investigate
Log.e(TAG, "got IllegalStateException. Are there two recorders running?", e);
// Pass back a message detail for dispatchErrorOccurred to
// show at user level
throw (new IllegalStateException("Is there another recording running?"));
}
}
void stop() {
recorder.setOnErrorListener(null);
recorder.setOnInfoListener(null);
recorder.stop();
recorder.reset();
recorder.release();
}
}
/*
* This is null when not recording, and contains the active RecordingState
* when recording.
*/
private RecordingController controller;
public SoundRecorder(final ComponentContainer container) {
super(container.$form());
}
/**
* Returns the path to the saved recording
*
* @return savedRecording path to recording
*/
@SimpleProperty(
description = "Specifies the path to the file where the recording should be stored. " +
"If this proprety is the empty string, then starting a recording will create a file in " +
"an appropriate location. If the property is not the empty string, it should specify " +
"a complete path to a file in an existing directory, including a file name with the " +
"extension .3gp." ,
category = PropertyCategory.BEHAVIOR)
public String SavedRecording() {
return savedRecording;
}
/**
* Specifies the path to the saved recording displayed by the label.
*
* @param pathName path to saved recording
*/
@DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_STRING,
defaultValue = "")
@SimpleProperty
public void SavedRecording(String pathName) {
savedRecording = pathName;
}
/**
* Starts recording.
*/
@SimpleFunction
public void Start() {
if (controller != null) {
Log.i(TAG, "Start() called, but already recording to " + controller.file);
return;
}
Log.i(TAG, "Start() called");
if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
form.dispatchErrorOccurredEvent(
this, "Start", ErrorMessages.ERROR_MEDIA_EXTERNAL_STORAGE_NOT_AVAILABLE);
return;
}
try {
controller = new RecordingController(savedRecording);
} catch (Throwable t) {
form.dispatchErrorOccurredEvent(
this, "Start", ErrorMessages.ERROR_SOUND_RECORDER_CANNOT_CREATE, t.getMessage());
return;
}
try {
controller.start();
} catch (Throwable t) {
// I'm commenting the next line out because stop can throw an error, and
// it's not clear to me how to handle that.
// controller.stop();
controller = null;
form.dispatchErrorOccurredEvent(
this, "Start", ErrorMessages.ERROR_SOUND_RECORDER_CANNOT_CREATE, t.getMessage());
return;
}
StartedRecording();
}
@Override
public void onError(MediaRecorder affectedRecorder, int what, int extra) {
if (controller == null || affectedRecorder != controller.recorder) {
Log.w(TAG, "onError called with wrong recorder. Ignoring.");
return;
}
form.dispatchErrorOccurredEvent(this, "onError", ErrorMessages.ERROR_SOUND_RECORDER);
try {
controller.stop();
} catch (Throwable e) {
Log.w(TAG, e.getMessage());
} finally {
controller = null;
StoppedRecording();
}
}
@Override
public void onInfo(MediaRecorder affectedRecorder, int what, int extra) {
if (controller == null || affectedRecorder != controller.recorder) {
Log.w(TAG, "onInfo called with wrong recorder. Ignoring.");
return;
}
switch (what) {
case MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED:
form.dispatchErrorOccurredEvent(this, "recording",
ErrorMessages.ERROR_SOUND_RECORDER_MAX_DURATION_REACHED);
break;
case MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED:
form.dispatchErrorOccurredEvent(this, "recording",
ErrorMessages.ERROR_SOUND_RECORDER_MAX_FILESIZE_REACHED);
break;
case MediaRecorder.MEDIA_RECORDER_INFO_UNKNOWN:
form.dispatchErrorOccurredEvent(this, "recording", ErrorMessages.ERROR_SOUND_RECORDER);
break;
default:
// value of `what` is not valid, probably device-specific debugging. escape early to prevent
// stoppage until we see an Android-defined error. See also:
// http://stackoverflow.com/questions/25785420/mediarecorder-oninfolistener-giving-an-895
return;
}
try {
Log.i(TAG, "Recoverable condition while recording. Will attempt to stop normally.");
controller.recorder.stop();
} catch(IllegalStateException e) {
Log.i(TAG, "SoundRecorder was not in a recording state.", e);
form.dispatchErrorOccurredEventDialog(this, "Stop",
ErrorMessages.ERROR_SOUND_RECORDER_ILLEGAL_STOP);
} finally {
controller = null;
StoppedRecording();
}
}
/**
* Stops recording.
*/
@SimpleFunction
public void Stop() {
if (controller == null) {
Log.i(TAG, "Stop() called, but already stopped.");
return;
}
try {
Log.i(TAG, "Stop() called");
Log.i(TAG, "stopping");
controller.stop();
Log.i(TAG, "Firing AfterSoundRecorded with " + controller.file);
AfterSoundRecorded(controller.file);
} catch (Throwable t) {
form.dispatchErrorOccurredEvent(this, "Stop", ErrorMessages.ERROR_SOUND_RECORDER);
} finally {
controller = null;
StoppedRecording();
}
}
@SimpleEvent(description = "Provides the location of the newly created sound.")
public void AfterSoundRecorded(final String sound) {
EventDispatcher.dispatchEvent(this, "AfterSoundRecorded", sound);
}
@SimpleEvent(description = "Indicates that the recorder has started, and can be stopped.")
public void StartedRecording() {
EventDispatcher.dispatchEvent(this, "StartedRecording");
}
@SimpleEvent(description = "Indicates that the recorder has stopped, and can be started again.")
public void StoppedRecording() {
EventDispatcher.dispatchEvent(this, "StoppedRecording");
}
}