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