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