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.databinding.DataBindingUtil;
21
22import com.google.common.collect.ImmutableSet;
23
24import java.io.File;
25import java.lang.ref.WeakReference;
26import java.text.SimpleDateFormat;
27import java.util.Date;
28import java.util.Locale;
29import java.util.Objects;
30import java.util.concurrent.CountDownLatch;
31import java.util.concurrent.TimeUnit;
32import java.util.Set;
33
34import eu.siacs.conversations.Config;
35import eu.siacs.conversations.R;
36import eu.siacs.conversations.databinding.ActivityRecordingBinding;
37import eu.siacs.conversations.ui.util.SettingsUtils;
38import eu.siacs.conversations.utils.ThemeHelper;
39import eu.siacs.conversations.utils.TimeFrameUtils;
40
41public class RecordingActivity extends Activity 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 setTheme(ThemeHelper.findDialog(this));
67 super.onCreate(savedInstanceState);
68 this.binding = DataBindingUtil.setContentView(this, R.layout.activity_recording);
69 this.binding.cancelButton.setOnClickListener(this);
70 this.binding.shareButton.setOnClickListener(this);
71 this.setFinishOnTouchOutside(false);
72 getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
73 }
74
75 @Override
76 protected void onResume() {
77 super.onResume();
78 SettingsUtils.applyScreenshotPreventionSetting(this);
79 }
80
81 @Override
82 protected void onStart() {
83 super.onStart();
84 if (!startRecording()) {
85 this.binding.shareButton.setEnabled(false);
86 this.binding.timer.setTextAppearance(this, R.style.TextAppearance_Conversations_Title);
87 this.binding.timer.setText(R.string.unable_to_start_recording);
88 }
89 }
90
91 @Override
92 protected void onStop() {
93 super.onStop();
94 if (mRecorder != null) {
95 mHandler.removeCallbacks(mTickExecutor);
96 stopRecording(false);
97 }
98 if (mFileObserver != null) {
99 mFileObserver.stopWatching();
100 }
101 }
102
103 protected SharedPreferences getPreferences() {
104 return PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
105 }
106
107 private static final Set<String> AAC_SENSITIVE_DEVICES =
108 new ImmutableSet.Builder<String>()
109 .add("FP4") // Fairphone 4 https://codeberg.org/monocles/monocles_chat/issues/133
110 .add("ONEPLUS A6000") // OnePlus 6 https://github.com/iNPUTmice/Conversations/issues/4329
111 .add("ONEPLUS A6003") // OnePlus 6 https://github.com/iNPUTmice/Conversations/issues/4329
112 .add("ONEPLUS A6010") // OnePlus 6T https://codeberg.org/monocles/monocles_chat/issues/133
113 .add("ONEPLUS A6013") // OnePlus 6T https://codeberg.org/monocles/monocles_chat/issues/133
114 .add("Pixel 4a") // Pixel 4a https://github.com/iNPUTmice/Conversations/issues/4223
115 .build();
116
117 private boolean startRecording() {
118 mRecorder = new MediaRecorder();
119 mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
120 final String userChosenCodec = getPreferences().getString("voice_message_codec", "");
121 final int outputFormat;
122 if (("opus".equals(userChosenCodec) || ("".equals(userChosenCodec) && Config.USE_OPUS_VOICE_MESSAGES)) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
123 outputFormat = MediaRecorder.OutputFormat.OGG;
124 mRecorder.setOutputFormat(outputFormat);
125 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS);
126 mRecorder.setAudioEncodingBitRate(32000);
127 } else if ("mpeg4".equals(userChosenCodec) || !Config.USE_OPUS_VOICE_MESSAGES) {
128 outputFormat = MediaRecorder.OutputFormat.MPEG_4;
129 mRecorder.setOutputFormat(outputFormat);
130 if (AAC_SENSITIVE_DEVICES.contains(Build.MODEL)) {
131 // Changing these three settings for AAC sensitive devices might lead to sporadically truncated (cut-off) voice messages.
132 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC);
133 mRecorder.setAudioSamplingRate(24_000);
134 mRecorder.setAudioEncodingBitRate(28_000);
135 } else {
136 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
137 mRecorder.setAudioSamplingRate(22_050);
138 mRecorder.setAudioEncodingBitRate(64_000);
139 }
140 } else {
141 outputFormat = MediaRecorder.OutputFormat.THREE_GPP;
142 mRecorder.setOutputFormat(outputFormat);
143 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB);
144 mRecorder.setAudioEncodingBitRate(23850);
145 mRecorder.setAudioSamplingRate(16000);
146 }
147 setupOutputFile(outputFormat);
148 mRecorder.setOutputFile(mOutputFile.getAbsolutePath());
149
150 try {
151 mRecorder.prepare();
152 mRecorder.start();
153 mStartTime = SystemClock.elapsedRealtime();
154 mHandler.postDelayed(mTickExecutor, 100);
155 Log.d(Config.LOGTAG, "started recording to " + mOutputFile.getAbsolutePath());
156 return true;
157 } catch (Exception e) {
158 Log.e(Config.LOGTAG, "prepare() failed ", e);
159 return false;
160 }
161 }
162
163 protected void stopRecording(final boolean saveFile) {
164 try {
165 mRecorder.stop();
166 mRecorder.release();
167 } catch (Exception e) {
168 if (saveFile) {
169 Toast.makeText(this, R.string.unable_to_save_recording, Toast.LENGTH_SHORT).show();
170 return;
171 }
172 } finally {
173 mRecorder = null;
174 mStartTime = 0;
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 if (outputFormat == MediaRecorder.OutputFormat.THREE_GPP) {
228 extension = "awb";
229 } else {
230 throw new IllegalStateException("Unrecognized output format");
231 }
232 final String filename =
233 String.format("RECORDING_%s.%s", dateFormat.format(new Date()), extension);
234 final File parentDirectory;
235 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
236 parentDirectory =
237 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_RECORDINGS);
238 } else {
239 parentDirectory =
240 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
241 }
242 final File conversationsDirectory = new File(parentDirectory, getString(R.string.app_name));
243 return new File(conversationsDirectory, filename);
244 }
245
246 private void setupOutputFile(final int outputFormat) {
247 mOutputFile = generateOutputFilename(outputFormat);
248 final File parentDirectory = mOutputFile.getParentFile();
249 if (Objects.requireNonNull(parentDirectory).mkdirs()) {
250 Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath());
251 }
252 setupFileObserver(parentDirectory);
253 }
254
255 private void setupFileObserver(final File directory) {
256 mFileObserver =
257 new FileObserver(directory.getAbsolutePath()) {
258 @Override
259 public void onEvent(int event, String s) {
260 if (s != null
261 && s.equals(mOutputFile.getName())
262 && event == FileObserver.CLOSE_WRITE) {
263 outputFileWrittenLatch.countDown();
264 }
265 }
266 };
267 mFileObserver.startWatching();
268 }
269
270 private void tick() {
271 this.binding.timer.setText(TimeFrameUtils.formatTimePassed(mStartTime, true));
272 }
273
274 @Override
275 public void onClick(final View view) {
276 switch (view.getId()) {
277 case R.id.cancel_button:
278 mHandler.removeCallbacks(mTickExecutor);
279 stopRecording(false);
280 setResult(RESULT_CANCELED);
281 finish();
282 break;
283 case R.id.share_button:
284 this.binding.shareButton.setEnabled(false);
285 this.binding.shareButton.setText(R.string.please_wait);
286 mHandler.removeCallbacks(mTickExecutor);
287 mHandler.postDelayed(() -> stopRecording(true), 500);
288 break;
289 }
290 }
291}