RecordingActivity.java

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