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