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