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}