Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve audio recording configuration #2752

Merged
merged 14 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/assets/locales/android_translatable_strings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -444,20 +444,24 @@ custom.restore.file.not.set=Custom restore file path is not set in preferences
custom.restore.error=Error loading custom sync

start.recording=Start Recording
start.recording.failed=Unable to start recording while another application is recording!
stop.recording=Stop Recording
recording.header=Record a sound
before.recording=Tap to start recording
before.overwrite.recording=Tap to start a new recording
during.recording=Tap to stop recording
during.recording=Recording is in progress, avoid navigating away from CommCare. Tap to stop recording
after.recording=Recording complete.
delete.recording=Tap to delete recording
pause.recording=Recording paused, tap to continue recording
pause.recording.because.no.sound.captured=Recording paused because another app started recording, tap to continue recording
save=Save
recording.cancel=Cancel
recording.clear=Clear
recording.prompt.with.file.chooser=Record or choose sound below
recording.prompt.without.file.chooser=Record sound below
recording.custom=Recorded Sound
recording.paused.due.another.app.recording.title=CommCare Audio Recording
recording.paused.due.another.app.recording.message=Recording paused as another app started recording. Click here to resume the recording!

callout.failure.dialer=Device is not currently configured to make telephone calls
callout.failure.sms=SMS app not found
Expand Down
1 change: 1 addition & 0 deletions app/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -455,4 +455,5 @@
<string name="fcm_notification">FCM Notification</string>
<string name="fcm_default_notification_channel">notification-channel-push-notifications</string>
<string name="app_with_id_not_found">Required CommCare App is not installed on device</string>
<string name="audio_recording_notification">Audio Recording Notification</string>
</resources>
11 changes: 9 additions & 2 deletions app/src/org/commcare/utils/MediaUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.AudioManager;
import android.os.Build;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.Pair;
import android.view.WindowManager;

import org.commcare.CommCareApplication;
import org.commcare.engine.references.JavaFileReference;
import org.commcare.google.services.analytics.AnalyticsParamValue;
import org.commcare.google.services.analytics.FirebaseAnalyticsUtil;
import org.commcare.preferences.HiddenPreferences;
import org.commcare.util.LogTypes;
import org.javarosa.core.reference.InvalidReferenceException;
Expand All @@ -27,6 +27,7 @@
import java.security.NoSuchAlgorithmException;

import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;

/**
* @author ctsims
Expand Down Expand Up @@ -506,4 +507,10 @@ private static Pair<Bitmap, Boolean> performSafeScaleDown(String imageFilepath,
}
}

@RequiresApi(api = Build.VERSION_CODES.N)
public static boolean isRecordingActive(Context context){
return ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE))
.getActiveRecordingConfigurations().size() > 0;
}

}
48 changes: 48 additions & 0 deletions app/src/org/commcare/utils/NotificationUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.commcare.utils;

import static android.content.Context.NOTIFICATION_SERVICE;

import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;

import androidx.core.app.NotificationCompat;

import org.commcare.activities.DispatchActivity;
import org.commcare.dalvik.R;

/**
* Set of methods to post notifications to the user.
*
* @author avazirna
*/
public class NotificationUtil {
public static void showNotification(Context context, String notificationChannel, int notificationId,
shubham1g5 marked this conversation as resolved.
Show resolved Hide resolved
String notificationTitle, String notificationText, Intent actionIntent) {

int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
pendingIntentFlags = pendingIntentFlags | PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent contentIntent =
PendingIntent.getActivity(context, 0, actionIntent, pendingIntentFlags);

NotificationCompat.Builder notification =
new NotificationCompat.Builder(context, notificationChannel)
.setContentTitle(notificationTitle)
.setContentText(notificationText)
.setContentIntent(contentIntent)
.setSmallIcon(R.drawable.notification)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setWhen(System.currentTimeMillis());
((NotificationManager) context.getSystemService(NOTIFICATION_SERVICE))
.notify(notificationId, notification.build());
}

public static void cancelNotification(Context context, int notificationId) {
((NotificationManager) context.getSystemService(NOTIFICATION_SERVICE))
.cancel(notificationId);
}
}
138 changes: 118 additions & 20 deletions app/src/org/commcare/views/widgets/RecordingFragment.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package org.commcare.views.widgets;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.media.AudioManager;
import android.media.AudioRecordingConfiguration;
import android.media.MediaCodecInfo;
import android.media.MediaCodecList;
import android.media.MediaPlayer;
Expand All @@ -23,17 +27,25 @@
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;

import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.DialogFragment;

import org.commcare.CommCareApplication;
import org.commcare.CommCareNoficationManager;
import org.commcare.activities.DispatchActivity;
import org.commcare.dalvik.R;
import org.commcare.utils.MediaUtil;
import org.commcare.utils.NotificationUtil;
import org.javarosa.core.services.locale.Localization;

import java.io.File;
import java.io.IOException;
import java.util.Date;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.DialogFragment;
import java.util.List;
import java.util.Optional;

/**
* A popup dialog fragment that handles recording_fragment and saving of audio
Expand All @@ -54,6 +66,7 @@ public class RecordingFragment extends DialogFragment {

private static final int HEAAC_SAMPLE_RATE = 44100;
private static final int AMRNB_SAMPLE_RATE = 8000;
private final int RECORDING_NOTIFICATION_ID = R.string.audio_recording_notification;

private String fileName;
private static final String FILE_EXT = ".mp3";
Expand All @@ -73,12 +86,12 @@ public class RecordingFragment extends DialogFragment {
private long mLastStopTime;
private boolean inPausedState = false;
private boolean savedRecordingExists = false;

private AudioManager.AudioRecordingCallback audioRecordingCallback;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
layout = (LinearLayout)inflater.inflate(R.layout.recording_fragment, container);
disableScreenRotation((AppCompatActivity)getContext());
layout = (LinearLayout) inflater.inflate(R.layout.recording_fragment, container);
disableScreenRotation((AppCompatActivity) getContext());
prepareButtons();
prepareText();
setWindowSize();
Expand Down Expand Up @@ -119,7 +132,7 @@ private void setWindowSize() {
Rect displayRectangle = new Rect();
Window window = getActivity().getWindow();
window.getDecorView().getWindowVisibleDisplayFrame(displayRectangle);
layout.setMinimumWidth((int)(displayRectangle.width() * 0.9f));
layout.setMinimumWidth((int) (displayRectangle.width() * 0.9f));
getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE);
}

Expand All @@ -142,7 +155,6 @@ private void setActionText(String textKey) {
actionButton.setText(Localization.get(textKey));
}


private void resetRecordingView() {
if (recorder != null) {
recorder.release();
Expand All @@ -165,7 +177,14 @@ private void resetRecordingView() {
}

private void startRecording() {
disableScreenRotation((AppCompatActivity)getContext());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (MediaUtil.isRecordingActive(getContext())) {
Toast.makeText(getContext(), Localization.get("start.recording.failed"), Toast.LENGTH_SHORT).show();
return;
}
}

disableScreenRotation((AppCompatActivity) getContext());
setCancelable(false);
setupRecorder();
recorder.start();
Expand All @@ -177,7 +196,7 @@ private void recordingInProgress() {
recordingDuration.start();
if (isPauseSupported()) {
toggleRecording.setBackgroundResource(R.drawable.pause);
toggleRecording.setOnClickListener(v -> pauseRecording());
toggleRecording.setOnClickListener(v -> pauseRecording(true));
} else {
toggleRecording.setBackgroundResource(R.drawable.record_in_progress);
toggleRecording.setOnClickListener(v -> stopRecording());
Expand All @@ -189,15 +208,21 @@ private void recordingInProgress() {
discardRecording.setVisibility(View.INVISIBLE);
}


private void setupRecorder() {
if (recorder == null) {
recorder = new MediaRecorder();
}

boolean isHeAacSupported = isHeAacEncoderSupported();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
recorder.setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION);
} else {
recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
}

recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
recorder.setPrivacySensitive(true);
}
recorder.setAudioSamplingRate(isHeAacSupported ? HEAAC_SAMPLE_RATE : AMRNB_SAMPLE_RATE);
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
recorder.setOutputFile(fileName);
Expand All @@ -206,6 +231,9 @@ private void setupRecorder() {
} else {
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
registerAudioRecordingConfigurationChangeCallback();
}
try {
recorder.prepare();
} catch (IOException e) {
Expand All @@ -230,7 +258,8 @@ private boolean isHeAacEncoderSupported() {
MediaCodecInfo.CodecProfileLevel[] profileLevels = cap.profileLevels;
for (MediaCodecInfo.CodecProfileLevel profileLevel : profileLevels) {
int profile = profileLevel.profile;
if (profile == MediaCodecInfo.CodecProfileLevel.AACObjectHE || profile == MediaCodecInfo.CodecProfileLevel.AACObjectHE_PS) {
if (profile == MediaCodecInfo.CodecProfileLevel.AACObjectHE
|| profile == MediaCodecInfo.CodecProfileLevel.AACObjectHE_PS) {
return true;
}
}
Expand Down Expand Up @@ -259,7 +288,7 @@ private void stopRecording() {
}

@SuppressLint("NewApi")
private void pauseRecording() {
private void pauseRecording(boolean pausedByUser) {
inPausedState = true;
recordingDuration.stop();
chronoPause();
Expand All @@ -268,7 +297,8 @@ private void pauseRecording() {
enableSave();
toggleRecording.setBackgroundResource(R.drawable.record_add);
toggleRecording.setOnClickListener(v -> resumeRecording());
instruction.setText(Localization.get("pause.recording"));
instruction.setText(Localization.get(pausedByUser ? "pause.recording"
: "pause.recording.because.no.sound.captured"));
}

private void enableSave() {
Expand All @@ -290,14 +320,16 @@ private boolean isPauseSupported() {
Bundle args = getArguments();
if (args != null) {
String appearance = args.getString(APPEARANCE_ATTR_ARG_KEY);
return LONG_APPEARANCE_VALUE.equals(appearance) &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
return LONG_APPEARANCE_VALUE.equals(appearance)
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
}
return false;
}


private void saveRecording() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
unregisterAudioRecordingConfigurationChangeCallback();
}
if (inPausedState) {
stopRecording();
}
Expand All @@ -318,7 +350,7 @@ public void setListener(RecordingCompletionListener listener) {
@Override
public void onDismiss(DialogInterface dialog) {
super.onDismiss(dialog);
enableScreenRotation((AppCompatActivity)getContext());
enableScreenRotation((AppCompatActivity) getContext());
if (recorder != null) {
recorder.release();
this.recorder = null;
Expand All @@ -328,7 +360,7 @@ public void onDismiss(DialogInterface dialog) {
try {
player.release();
} catch (IllegalStateException e) {
//Do nothing because player wasn't recording
// Do nothing because player wasn't recording
}
}
}
Expand Down Expand Up @@ -393,4 +425,70 @@ private void chronoResume() {
}
recordingDuration.start();
}

@RequiresApi(api = Build.VERSION_CODES.N)
private void registerAudioRecordingConfigurationChangeCallback() {
audioRecordingCallback = new AudioManager.AudioRecordingCallback() {
@Override
public void onRecordingConfigChanged(List<AudioRecordingConfiguration> configs) {
super.onRecordingConfigChanged(configs);
if (recorder == null) {
return;
}

if (hasRecordingGoneSilent(configs)) {
if (!inPausedState) {
pauseRecording(false);
NotificationUtil.showNotification(
shubham1g5 marked this conversation as resolved.
Show resolved Hide resolved
getContext(),
CommCareNoficationManager.NOTIFICATION_CHANNEL_USER_SESSION_ID,
RECORDING_NOTIFICATION_ID,
Localization.get("recording.paused.due.another.app.recording.title"),
Localization.get("recording.paused.due.another.app.recording.message"),
new Intent(getContext(), DispatchActivity.class)
shubham1g5 marked this conversation as resolved.
Show resolved Hide resolved
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER));
}
} else {
if (inPausedState) {
NotificationUtil.cancelNotification(getContext(), RECORDING_NOTIFICATION_ID);
}
}
}
};
((AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE))
.registerAudioRecordingCallback(audioRecordingCallback, null);
}

@RequiresApi(api = Build.VERSION_CODES.N)
private void unregisterAudioRecordingConfigurationChangeCallback() {
if (audioRecordingCallback != null) {
((AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE))
.unregisterAudioRecordingCallback(audioRecordingCallback);
audioRecordingCallback = null;
}
}

private boolean hasRecordingGoneSilent(List<AudioRecordingConfiguration> configs) {
if (recorder == null) {
return false;
}

if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
if (recorder.getActiveRecordingConfiguration() == null) {
return false;
}

Optional<AudioRecordingConfiguration> currentAudioConfig = configs.stream().filter(config ->
config.getClientAudioSessionId() == recorder.getActiveRecordingConfiguration()
.getClientAudioSessionId())
.findAny();
return currentAudioConfig.isPresent() ? currentAudioConfig.get().isClientSilenced() : false;
} else {
if (recorder.getMaxAmplitude() == 0) {
return true;
}
return false;
}
}
}