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