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                    .add("WP12 Pro")        // Oukitel WP 12 Pro https://github.com/iNPUTmice/Conversations/issues/4223
110                    .add("Volla Phone X")   // Volla Phone X https://github.com/iNPUTmice/Conversations/issues/4223
111                    .build();
112
113    private boolean startRecording() {
114        mRecorder = new MediaRecorder();
115        mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
116        final int outputFormat;
117        if (Config.USE_OPUS_VOICE_MESSAGES && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
118            outputFormat = MediaRecorder.OutputFormat.OGG;
119            mRecorder.setOutputFormat(outputFormat);
120            mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS);
121            mRecorder.setAudioEncodingBitRate(32_000);
122        } else {
123            outputFormat = MediaRecorder.OutputFormat.MPEG_4;
124            mRecorder.setOutputFormat(outputFormat);
125            if (AAC_SENSITIVE_DEVICES.contains(Build.MODEL)) {
126                // Changing these three settings for AAC sensitive devices might lead to sporadically truncated (cut-off) voice messages.
127                mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC);
128                mRecorder.setAudioSamplingRate(24_000);
129                mRecorder.setAudioEncodingBitRate(28_000);
130            } else {
131                mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
132                mRecorder.setAudioSamplingRate(22_050);
133                mRecorder.setAudioEncodingBitRate(64_000);
134            }
135        }
136        setupOutputFile(outputFormat);
137        mRecorder.setOutputFile(mOutputFile.getAbsolutePath());
138
139        try {
140            mRecorder.prepare();
141            mRecorder.start();
142            mStartTime = SystemClock.elapsedRealtime();
143            mHandler.postDelayed(mTickExecutor, 100);
144            Log.d(Config.LOGTAG, "started recording to " + mOutputFile.getAbsolutePath());
145            return true;
146        } catch (Exception e) {
147            Log.e(Config.LOGTAG, "prepare() failed ", e);
148            return false;
149        }
150    }
151
152    protected void stopRecording(final boolean saveFile) {
153        try {
154            mRecorder.stop();
155            mRecorder.release();
156        } catch (Exception e) {
157            if (saveFile) {
158                Toast.makeText(this, R.string.unable_to_save_recording, Toast.LENGTH_SHORT).show();
159                return;
160            }
161        } finally {
162            mRecorder = null;
163            mStartTime = 0;
164        }
165        if (!saveFile && mOutputFile != null) {
166            if (mOutputFile.delete()) {
167                Log.d(Config.LOGTAG, "deleted canceled recording");
168            }
169        }
170        if (saveFile) {
171            new Thread(new Finisher(outputFileWrittenLatch, mOutputFile, this)).start();
172        }
173    }
174
175    private static class Finisher implements Runnable {
176
177        private final CountDownLatch latch;
178        private final File outputFile;
179        private final WeakReference<Activity> activityReference;
180
181        private Finisher(CountDownLatch latch, File outputFile, Activity activity) {
182            this.latch = latch;
183            this.outputFile = outputFile;
184            this.activityReference = new WeakReference<>(activity);
185        }
186
187        @Override
188        public void run() {
189            try {
190                if (!latch.await(8, TimeUnit.SECONDS)) {
191                    Log.d(Config.LOGTAG, "time out waiting for output file to be written");
192                }
193            } catch (final InterruptedException e) {
194                Log.d(Config.LOGTAG, "interrupted while waiting for output file to be written", e);
195            }
196            final Activity activity = activityReference.get();
197            if (activity == null) {
198                return;
199            }
200            activity.runOnUiThread(
201                    () -> {
202                        activity.setResult(
203                                Activity.RESULT_OK, new Intent().setData(Uri.fromFile(outputFile)));
204                        activity.finish();
205                    });
206        }
207    }
208
209    private File generateOutputFilename(final int outputFormat) {
210        final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
211        final String extension;
212        if (outputFormat == MediaRecorder.OutputFormat.MPEG_4) {
213            extension = "m4a";
214        } else if (outputFormat == MediaRecorder.OutputFormat.OGG) {
215            extension = "oga";
216        } else {
217            throw new IllegalStateException("Unrecognized output format");
218        }
219        final String filename =
220                String.format("RECORDING_%s.%s", dateFormat.format(new Date()), extension);
221        final File parentDirectory;
222        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
223            parentDirectory =
224                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_RECORDINGS);
225        } else {
226            parentDirectory =
227                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
228        }
229        final File conversationsDirectory = new File(parentDirectory, getString(R.string.app_name));
230        return new File(conversationsDirectory, filename);
231    }
232
233    private void setupOutputFile(final int outputFormat) {
234        mOutputFile = generateOutputFilename(outputFormat);
235        final File parentDirectory = mOutputFile.getParentFile();
236        if (Objects.requireNonNull(parentDirectory).mkdirs()) {
237            Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath());
238        }
239        setupFileObserver(parentDirectory);
240    }
241
242    private void setupFileObserver(final File directory) {
243        mFileObserver =
244                new FileObserver(directory.getAbsolutePath()) {
245                    @Override
246                    public void onEvent(int event, String s) {
247                        if (s != null
248                                && s.equals(mOutputFile.getName())
249                                && event == FileObserver.CLOSE_WRITE) {
250                            outputFileWrittenLatch.countDown();
251                        }
252                    }
253                };
254        mFileObserver.startWatching();
255    }
256
257    private void tick() {
258        this.binding.timer.setText(TimeFrameUtils.formatTimePassed(mStartTime, true));
259    }
260
261    @Override
262    public void onClick(final View view) {
263        switch (view.getId()) {
264            case R.id.cancel_button:
265                mHandler.removeCallbacks(mTickExecutor);
266                stopRecording(false);
267                setResult(RESULT_CANCELED);
268                finish();
269                break;
270            case R.id.share_button:
271                this.binding.shareButton.setEnabled(false);
272                this.binding.shareButton.setText(R.string.please_wait);
273                mHandler.removeCallbacks(mTickExecutor);
274                mHandler.postDelayed(() -> stopRecording(true), 500);
275                break;
276        }
277    }
278}