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