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