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