RecordingActivity.java

  1package eu.siacs.conversations.ui;
  2
  3import android.app.Activity;
  4import android.content.Intent;
  5import android.content.SharedPreferences;
  6import android.media.MediaRecorder;
  7import android.net.Uri;
  8import android.os.Build;
  9import android.os.Bundle;
 10import android.os.Environment;
 11import android.os.FileObserver;
 12import android.os.Handler;
 13import android.os.SystemClock;
 14import android.preference.PreferenceManager;
 15import android.util.Log;
 16import android.view.View;
 17import android.view.WindowManager;
 18import android.widget.Toast;
 19
 20import androidx.appcompat.app.AppCompatActivity;
 21import androidx.databinding.DataBindingUtil;
 22
 23import com.google.common.collect.ImmutableSet;
 24
 25import java.io.File;
 26import java.lang.ref.WeakReference;
 27import java.text.SimpleDateFormat;
 28import java.util.Date;
 29import java.util.Locale;
 30import java.util.Objects;
 31import java.util.concurrent.CountDownLatch;
 32import java.util.concurrent.TimeUnit;
 33import java.util.Set;
 34
 35import eu.siacs.conversations.Config;
 36import eu.siacs.conversations.R;
 37import eu.siacs.conversations.databinding.ActivityRecordingBinding;
 38import eu.siacs.conversations.ui.util.SettingsUtils;
 39import eu.siacs.conversations.utils.TimeFrameUtils;
 40
 41public class RecordingActivity extends BaseActivity implements View.OnClickListener {
 42
 43    private ActivityRecordingBinding binding;
 44
 45    private MediaRecorder mRecorder;
 46    private long mStartTime = 0;
 47
 48    private final CountDownLatch outputFileWrittenLatch = new CountDownLatch(1);
 49
 50    private final Handler mHandler = new Handler();
 51    private final Runnable mTickExecutor =
 52            new Runnable() {
 53                @Override
 54                public void run() {
 55                    tick();
 56                    mHandler.postDelayed(mTickExecutor, 100);
 57                }
 58            };
 59
 60    private File mOutputFile;
 61
 62    private FileObserver mFileObserver;
 63
 64    @Override
 65    protected void onCreate(Bundle savedInstanceState) {
 66        super.onCreate(savedInstanceState);
 67        this.binding = DataBindingUtil.setContentView(this, R.layout.activity_recording);
 68        this.binding.cancelButton.setOnClickListener(this);
 69        this.binding.shareButton.setOnClickListener(this);
 70        this.setFinishOnTouchOutside(false);
 71        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 72    }
 73    @Override
 74    public void onStart() {
 75        super.onStart();
 76        if (!startRecording()) {
 77            this.binding.shareButton.setEnabled(false);
 78            this.binding.timer.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
 79            // TODO reset font family. make red?
 80            this.binding.timer.setText(R.string.unable_to_start_recording);
 81        }
 82    }
 83
 84    @Override
 85    protected void onStop() {
 86        super.onStop();
 87        if (mRecorder != null) {
 88            mHandler.removeCallbacks(mTickExecutor);
 89            stopRecording(false);
 90        }
 91        if (mFileObserver != null) {
 92            mFileObserver.stopWatching();
 93        }
 94    }
 95
 96    protected SharedPreferences getPreferences() {
 97        return PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
 98    }
 99
100    private static final Set<String> AAC_SENSITIVE_DEVICES =
101            new ImmutableSet.Builder<String>()
102                    .add("FP4")             // Fairphone 4 https://codeberg.org/monocles/monocles_chat/issues/133
103                    .add("ONEPLUS A6000")   // OnePlus 6 https://github.com/iNPUTmice/Conversations/issues/4329
104                    .add("ONEPLUS A6003")   // OnePlus 6 https://github.com/iNPUTmice/Conversations/issues/4329
105                    .add("ONEPLUS A6010")   // OnePlus 6T https://codeberg.org/monocles/monocles_chat/issues/133
106                    .add("ONEPLUS A6013")   // OnePlus 6T https://codeberg.org/monocles/monocles_chat/issues/133
107                    .add("Pixel 4a")        // Pixel 4a https://github.com/iNPUTmice/Conversations/issues/4223
108                    .add("WP12 Pro")        // Oukitel WP 12 Pro https://github.com/iNPUTmice/Conversations/issues/4223
109                    .add("Volla Phone X")   // Volla Phone X https://github.com/iNPUTmice/Conversations/issues/4223
110                    .build();
111
112    private boolean startRecording() {
113        mRecorder = new MediaRecorder();
114        final String userChosenCodec = getPreferences().getString("voice_message_codec", "");
115        try {
116            mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
117        } catch (final RuntimeException e) {
118            Log.e(Config.LOGTAG,"could not set audio source", e);
119            return false;
120        }
121        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
122            mRecorder.setPrivacySensitive(true);
123        }
124        final int outputFormat;
125        if (("opus".equals(userChosenCodec) || ("".equals(userChosenCodec) && Config.USE_OPUS_VOICE_MESSAGES)) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
126            outputFormat = MediaRecorder.OutputFormat.OGG;
127            mRecorder.setOutputFormat(outputFormat);
128            mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS);
129            mRecorder.setAudioSamplingRate(48000);
130            mRecorder.setAudioEncodingBitRate(32000);
131        } else if ("mpeg4".equals(userChosenCodec) || !Config.USE_OPUS_VOICE_MESSAGES) {
132            outputFormat = MediaRecorder.OutputFormat.MPEG_4;
133            mRecorder.setOutputFormat(outputFormat);
134            if (AAC_SENSITIVE_DEVICES.contains(Build.MODEL) && Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
135                // Changing these three settings for AAC sensitive devices for Android<=13 might lead to sporadically truncated (cut-off) voice messages.
136                mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC);
137                mRecorder.setAudioSamplingRate(24_000);
138                mRecorder.setAudioEncodingBitRate(28_000);
139            } else {
140                mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
141                mRecorder.setAudioSamplingRate(44_100);
142                mRecorder.setAudioEncodingBitRate(64_000);
143            }
144        } else {
145            outputFormat = MediaRecorder.OutputFormat.THREE_GPP;
146            mRecorder.setOutputFormat(outputFormat);
147            mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB);
148            mRecorder.setAudioEncodingBitRate(23850);
149            mRecorder.setAudioSamplingRate(16000);
150        }
151        setupOutputFile(outputFormat);
152        mRecorder.setOutputFile(mOutputFile.getAbsolutePath());
153
154        try {
155            mRecorder.prepare();
156            mRecorder.start();
157            mStartTime = SystemClock.elapsedRealtime();
158            mHandler.postDelayed(mTickExecutor, 100);
159            Log.d(Config.LOGTAG, "started recording to " + mOutputFile.getAbsolutePath());
160            return true;
161        } catch (Exception e) {
162            Log.e(Config.LOGTAG, "prepare() failed ", e);
163            return false;
164        }
165    }
166
167    protected void stopRecording(final boolean saveFile) {
168        try {
169            mRecorder.stop();
170            mRecorder.release();
171        } catch (Exception e) {
172            if (saveFile) {
173                Toast.makeText(this, R.string.unable_to_save_recording, Toast.LENGTH_SHORT).show();
174                return;
175            }
176        } finally {
177            mRecorder = null;
178            mStartTime = 0;
179        }
180        if (!saveFile && mOutputFile != null) {
181            if (mOutputFile.delete()) {
182                Log.d(Config.LOGTAG, "deleted canceled recording");
183            }
184        }
185        if (saveFile) {
186            new Thread(new Finisher(outputFileWrittenLatch, mOutputFile, this)).start();
187        }
188    }
189
190    private static class Finisher implements Runnable {
191
192        private final CountDownLatch latch;
193        private final File outputFile;
194        private final WeakReference<Activity> activityReference;
195
196        private Finisher(CountDownLatch latch, File outputFile, Activity activity) {
197            this.latch = latch;
198            this.outputFile = outputFile;
199            this.activityReference = new WeakReference<>(activity);
200        }
201
202        @Override
203        public void run() {
204            try {
205                if (!latch.await(8, TimeUnit.SECONDS)) {
206                    Log.d(Config.LOGTAG, "time out waiting for output file to be written");
207                }
208            } catch (final InterruptedException e) {
209                Log.d(Config.LOGTAG, "interrupted while waiting for output file to be written", e);
210            }
211            final Activity activity = activityReference.get();
212            if (activity == null) {
213                return;
214            }
215            activity.runOnUiThread(
216                    () -> {
217                        activity.setResult(
218                                Activity.RESULT_OK, new Intent().setData(Uri.fromFile(outputFile)));
219                        activity.finish();
220                    });
221        }
222    }
223
224    private File generateOutputFilename(final int outputFormat) {
225        final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
226        final String extension;
227        if (outputFormat == MediaRecorder.OutputFormat.MPEG_4) {
228            extension = "m4a";
229        } else if (outputFormat == MediaRecorder.OutputFormat.OGG) {
230            extension = "oga";
231        } else if (outputFormat == MediaRecorder.OutputFormat.THREE_GPP) {
232            extension = "awb";
233        } else {
234            throw new IllegalStateException("Unrecognized output format");
235        }
236        final String filename =
237                String.format("RECORDING_%s.%s", dateFormat.format(new Date()), extension);
238        final File parentDirectory;
239        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
240            parentDirectory =
241                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_RECORDINGS);
242        } else {
243            parentDirectory =
244                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
245        }
246        final File conversationsDirectory = new File(parentDirectory, getString(R.string.app_name));
247        return new File(conversationsDirectory, filename);
248    }
249
250    private void setupOutputFile(final int outputFormat) {
251        mOutputFile = generateOutputFilename(outputFormat);
252        final File parentDirectory = mOutputFile.getParentFile();
253        if (Objects.requireNonNull(parentDirectory).mkdirs()) {
254            Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath());
255        }
256        setupFileObserver(parentDirectory);
257    }
258
259    private void setupFileObserver(final File directory) {
260        mFileObserver =
261                new FileObserver(directory.getAbsolutePath()) {
262                    @Override
263                    public void onEvent(int event, String s) {
264                        if (s != null
265                                && s.equals(mOutputFile.getName())
266                                && event == FileObserver.CLOSE_WRITE) {
267                            outputFileWrittenLatch.countDown();
268                        }
269                    }
270                };
271        mFileObserver.startWatching();
272    }
273
274    private void tick() {
275        this.binding.timer.setText(TimeFrameUtils.formatTimePassed(mStartTime, true));
276    }
277
278    @Override
279    public void onClick(final View view) {
280        switch (view.getId()) {
281            case R.id.cancel_button:
282                mHandler.removeCallbacks(mTickExecutor);
283                stopRecording(false);
284                setResult(RESULT_CANCELED);
285                finish();
286                break;
287            case R.id.share_button:
288                this.binding.shareButton.setEnabled(false);
289                this.binding.shareButton.setText(R.string.please_wait);
290                mHandler.removeCallbacks(mTickExecutor);
291                mHandler.postDelayed(() -> stopRecording(true), 500);
292                break;
293        }
294    }
295}