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