RecordingActivity.java

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