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