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 2") // Pixel 2
132 // https://codeberg.org/iNPUTmice/Conversations/issues/526
133 .add("Pixel 4a") // Pixel 4a
134 // https://github.com/iNPUTmice/Conversations/issues/4223
135 .add("SC-03K") // Samsung Galaxy S9+
136 .add("SCV39") // Samsung Galaxy S9+
137 .add("SM-G965F") // Samsung Galaxy S9+
138 .add("SM-G965N") // Samsung Galaxy S9+
139 .add("SM-G9650") // Samsung Galaxy S9+
140 .add("SM-G965W") // Samsung Galaxy S9+
141 .add("SM-G965U") // Samsung Galaxy S9+
142 .add("SM-G965U1") // Samsung Galaxy S9+
143 // https://codeberg.org/iNPUTmice/Conversations/issues/526
144 .add("WP12 Pro") // Oukitel WP 12 Pro
145 // https://github.com/iNPUTmice/Conversations/issues/4223
146 .add("Volla Phone X") // Volla Phone X
147 // https://github.com/iNPUTmice/Conversations/issues/4223
148 .build();
149
150 private boolean startRecording() {
151 mRecorder = new MediaRecorder();
152 stopwatch = Stopwatch.createUnstarted();
153 try {
154 mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
155 } catch (final RuntimeException e) {
156 Log.e(Config.LOGTAG, "could not set audio source", e);
157 return false;
158 }
159 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
160 mRecorder.setPrivacySensitive(true);
161 }
162 final int outputFormat;
163 if (Config.USE_OPUS_VOICE_MESSAGES && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
164 outputFormat = MediaRecorder.OutputFormat.OGG;
165 mRecorder.setOutputFormat(outputFormat);
166 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS);
167 mRecorder.setAudioEncodingBitRate(32_000);
168 } else {
169 outputFormat = MediaRecorder.OutputFormat.MPEG_4;
170 mRecorder.setOutputFormat(outputFormat);
171 if (AAC_SENSITIVE_DEVICES.contains(Build.MODEL)
172 && Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
173 // Changing these three settings for AAC sensitive devices for Android<=13 might
174 // lead to sporadically truncated (cut-off) voice messages.
175 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC);
176 mRecorder.setAudioSamplingRate(24_000);
177 mRecorder.setAudioEncodingBitRate(28_000);
178 } else {
179 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
180 mRecorder.setAudioSamplingRate(44_100);
181 mRecorder.setAudioEncodingBitRate(64_000);
182 }
183 }
184 setupOutputFile(outputFormat);
185 mRecorder.setOutputFile(mOutputFile.getAbsolutePath());
186
187 try {
188 mRecorder.prepare();
189 mRecorder.start();
190 stopwatch.start();
191 mHandler.postDelayed(mTickExecutor, 100);
192 Log.d(Config.LOGTAG, "started recording to " + mOutputFile.getAbsolutePath());
193 return true;
194 } catch (Exception e) {
195 Log.e(Config.LOGTAG, "prepare() failed ", e);
196 return false;
197 }
198 }
199
200 protected void stopRecording(final boolean saveFile) {
201 try {
202 mRecorder.stop();
203 mRecorder.release();
204 if (stopwatch.isRunning()) {
205 stopwatch.stop();
206 }
207 } catch (final Exception e) {
208 Log.d(Config.LOGTAG, "could not save recording", e);
209 if (saveFile) {
210 Toast.makeText(this, R.string.unable_to_save_recording, Toast.LENGTH_SHORT).show();
211 return;
212 }
213 } finally {
214 mRecorder = null;
215 }
216 if (!saveFile && mOutputFile != null) {
217 if (mOutputFile.delete()) {
218 Log.d(Config.LOGTAG, "deleted canceled recording");
219 }
220 }
221 if (saveFile) {
222 new Thread(new Finisher(outputFileWrittenLatch, mOutputFile, this)).start();
223 }
224 }
225
226 private static class Finisher implements Runnable {
227
228 private final CountDownLatch latch;
229 private final File outputFile;
230 private final WeakReference<Activity> activityReference;
231
232 private Finisher(CountDownLatch latch, File outputFile, Activity activity) {
233 this.latch = latch;
234 this.outputFile = outputFile;
235 this.activityReference = new WeakReference<>(activity);
236 }
237
238 @Override
239 public void run() {
240 try {
241 if (!latch.await(8, TimeUnit.SECONDS)) {
242 Log.d(Config.LOGTAG, "time out waiting for output file to be written");
243 }
244 } catch (final InterruptedException e) {
245 Log.d(Config.LOGTAG, "interrupted while waiting for output file to be written", e);
246 }
247 final Activity activity = activityReference.get();
248 if (activity == null) {
249 return;
250 }
251 activity.runOnUiThread(
252 () -> {
253 activity.setResult(
254 Activity.RESULT_OK, new Intent().setData(Uri.fromFile(outputFile)));
255 activity.finish();
256 });
257 }
258 }
259
260 private File generateOutputFilename(final int outputFormat) {
261 final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
262 final String extension;
263 if (outputFormat == MediaRecorder.OutputFormat.MPEG_4) {
264 extension = "m4a";
265 } else if (outputFormat == MediaRecorder.OutputFormat.OGG) {
266 extension = "oga";
267 } else {
268 throw new IllegalStateException("Unrecognized output format");
269 }
270 final String filename =
271 String.format("RECORDING_%s.%s", dateFormat.format(new Date()), extension);
272 final File parentDirectory;
273 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
274 parentDirectory =
275 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_RECORDINGS);
276 } else {
277 parentDirectory =
278 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
279 }
280 final File conversationsDirectory = new File(parentDirectory, getString(R.string.app_name));
281 return new File(conversationsDirectory, filename);
282 }
283
284 private void setupOutputFile(final int outputFormat) {
285 mOutputFile = generateOutputFilename(outputFormat);
286 final File parentDirectory = mOutputFile.getParentFile();
287 if (Objects.requireNonNull(parentDirectory).mkdirs()) {
288 Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath());
289 }
290 setupFileObserver(parentDirectory);
291 }
292
293 private void setupFileObserver(final File directory) {
294 mFileObserver =
295 new FileObserver(directory.getAbsolutePath()) {
296 @Override
297 public void onEvent(int event, String s) {
298 if (s != null
299 && s.equals(mOutputFile.getName())
300 && event == FileObserver.CLOSE_WRITE) {
301 outputFileWrittenLatch.countDown();
302 }
303 }
304 };
305 mFileObserver.startWatching();
306 }
307
308 private void tick() {
309 this.binding.timer.setText(
310 TimeFrameUtils.formatElapsedTime(stopwatch.elapsed(TimeUnit.MILLISECONDS), true));
311 }
312
313 @Override
314 public void onClick(final View view) {
315 if (view.getId() == R.id.cancel_button) {
316 mHandler.removeCallbacks(mTickExecutor);
317 stopRecording(false);
318 setResult(RESULT_CANCELED);
319 finish();
320 } else if (view.getId() == R.id.share_button) {
321 this.binding.timer.setOnClickListener(null);
322 this.binding.shareButton.setEnabled(false);
323 this.binding.shareButton.setText(R.string.please_wait);
324 mHandler.removeCallbacks(mTickExecutor);
325 mHandler.postDelayed(() -> stopRecording(true), 500);
326 }
327 }
328}