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