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 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;
30
31import eu.siacs.conversations.Config;
32import eu.siacs.conversations.R;
33import eu.siacs.conversations.databinding.ActivityRecordingBinding;
34import eu.siacs.conversations.ui.util.SettingsUtils;
35import eu.siacs.conversations.utils.ThemeHelper;
36import eu.siacs.conversations.utils.TimeFrameUtils;
37
38public class RecordingActivity extends Activity implements View.OnClickListener {
39
40 private ActivityRecordingBinding binding;
41
42 private MediaRecorder mRecorder;
43 private long mStartTime = 0;
44
45 private final CountDownLatch outputFileWrittenLatch = new CountDownLatch(1);
46
47 private final Handler mHandler = new Handler();
48 private final Runnable mTickExecutor =
49 new Runnable() {
50 @Override
51 public void run() {
52 tick();
53 mHandler.postDelayed(mTickExecutor, 100);
54 }
55 };
56
57 private File mOutputFile;
58
59 private FileObserver mFileObserver;
60
61 @Override
62 protected void onCreate(Bundle savedInstanceState) {
63 setTheme(ThemeHelper.findDialog(this));
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
72 @Override
73 protected void onResume() {
74 super.onResume();
75 SettingsUtils.applyScreenshotPreventionSetting(this);
76 }
77
78 @Override
79 protected void onStart() {
80 super.onStart();
81 if (!startRecording()) {
82 this.binding.shareButton.setEnabled(false);
83 this.binding.timer.setTextAppearance(this, R.style.TextAppearance_Conversations_Title);
84 this.binding.timer.setText(R.string.unable_to_start_recording);
85 }
86 }
87
88 @Override
89 protected void onStop() {
90 super.onStop();
91 if (mRecorder != null) {
92 mHandler.removeCallbacks(mTickExecutor);
93 stopRecording(false);
94 }
95 if (mFileObserver != null) {
96 mFileObserver.stopWatching();
97 }
98 }
99
100 protected SharedPreferences getPreferences() {
101 return PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
102 }
103
104 private boolean startRecording() {
105 mRecorder = new MediaRecorder();
106 mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
107 final String userChosenCodec = getPreferences().getString("voice_message_codec", "");
108 final int outputFormat;
109 if (("opus".equals(userChosenCodec) || ("".equals(userChosenCodec) && Config.USE_OPUS_VOICE_MESSAGES)) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
110 outputFormat = MediaRecorder.OutputFormat.OGG;
111 mRecorder.setOutputFormat(outputFormat);
112 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS);
113 mRecorder.setAudioEncodingBitRate(32000);
114 } else if ("mpeg4".equals(userChosenCodec) || !Config.USE_OPUS_VOICE_MESSAGES) {
115 outputFormat = MediaRecorder.OutputFormat.MPEG_4;
116 mRecorder.setOutputFormat(outputFormat);
117 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
118 mRecorder.setAudioEncodingBitRate(96000);
119 mRecorder.setAudioSamplingRate(22050);
120 } else {
121 outputFormat = MediaRecorder.OutputFormat.THREE_GPP;
122 mRecorder.setOutputFormat(outputFormat);
123 mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB);
124 mRecorder.setAudioEncodingBitRate(23850);
125 mRecorder.setAudioSamplingRate(16000);
126 }
127 setupOutputFile(outputFormat);
128 mRecorder.setOutputFile(mOutputFile.getAbsolutePath());
129
130 try {
131 mRecorder.prepare();
132 mRecorder.start();
133 mStartTime = SystemClock.elapsedRealtime();
134 mHandler.postDelayed(mTickExecutor, 100);
135 Log.d(Config.LOGTAG, "started recording to " + mOutputFile.getAbsolutePath());
136 return true;
137 } catch (Exception e) {
138 Log.e(Config.LOGTAG, "prepare() failed ", e);
139 return false;
140 }
141 }
142
143 protected void stopRecording(final boolean saveFile) {
144 try {
145 mRecorder.stop();
146 mRecorder.release();
147 } catch (Exception e) {
148 if (saveFile) {
149 Toast.makeText(this, R.string.unable_to_save_recording, Toast.LENGTH_SHORT).show();
150 return;
151 }
152 } finally {
153 mRecorder = null;
154 mStartTime = 0;
155 }
156 if (!saveFile && mOutputFile != null) {
157 if (mOutputFile.delete()) {
158 Log.d(Config.LOGTAG, "deleted canceled recording");
159 }
160 }
161 if (saveFile) {
162 new Thread(new Finisher(outputFileWrittenLatch, mOutputFile, this)).start();
163 }
164 }
165
166 private static class Finisher implements Runnable {
167
168 private final CountDownLatch latch;
169 private final File outputFile;
170 private final WeakReference<Activity> activityReference;
171
172 private Finisher(CountDownLatch latch, File outputFile, Activity activity) {
173 this.latch = latch;
174 this.outputFile = outputFile;
175 this.activityReference = new WeakReference<>(activity);
176 }
177
178 @Override
179 public void run() {
180 try {
181 if (!latch.await(8, TimeUnit.SECONDS)) {
182 Log.d(Config.LOGTAG, "time out waiting for output file to be written");
183 }
184 } catch (final InterruptedException e) {
185 Log.d(Config.LOGTAG, "interrupted while waiting for output file to be written", e);
186 }
187 final Activity activity = activityReference.get();
188 if (activity == null) {
189 return;
190 }
191 activity.runOnUiThread(
192 () -> {
193 activity.setResult(
194 Activity.RESULT_OK, new Intent().setData(Uri.fromFile(outputFile)));
195 activity.finish();
196 });
197 }
198 }
199
200 private File generateOutputFilename(final int outputFormat) {
201 final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
202 final String extension;
203 if (outputFormat == MediaRecorder.OutputFormat.MPEG_4) {
204 extension = "m4a";
205 } else if (outputFormat == MediaRecorder.OutputFormat.OGG) {
206 extension = "oga";
207 } else if (outputFormat == MediaRecorder.OutputFormat.THREE_GPP) {
208 extension = "awb";
209 } else {
210 throw new IllegalStateException("Unrecognized output format");
211 }
212 final String filename =
213 String.format("RECORDING_%s.%s", dateFormat.format(new Date()), extension);
214 final File parentDirectory;
215 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
216 parentDirectory =
217 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_RECORDINGS);
218 } else {
219 parentDirectory =
220 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
221 }
222 final File conversationsDirectory = new File(parentDirectory, getString(R.string.app_name));
223 return new File(conversationsDirectory, filename);
224 }
225
226 private void setupOutputFile(final int outputFormat) {
227 mOutputFile = generateOutputFilename(outputFormat);
228 final File parentDirectory = mOutputFile.getParentFile();
229 if (Objects.requireNonNull(parentDirectory).mkdirs()) {
230 Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath());
231 }
232 setupFileObserver(parentDirectory);
233 }
234
235 private void setupFileObserver(final File directory) {
236 mFileObserver =
237 new FileObserver(directory.getAbsolutePath()) {
238 @Override
239 public void onEvent(int event, String s) {
240 if (s != null
241 && s.equals(mOutputFile.getName())
242 && event == FileObserver.CLOSE_WRITE) {
243 outputFileWrittenLatch.countDown();
244 }
245 }
246 };
247 mFileObserver.startWatching();
248 }
249
250 private void tick() {
251 this.binding.timer.setText(TimeFrameUtils.formatTimePassed(mStartTime, true));
252 }
253
254 @Override
255 public void onClick(final View view) {
256 switch (view.getId()) {
257 case R.id.cancel_button:
258 mHandler.removeCallbacks(mTickExecutor);
259 stopRecording(false);
260 setResult(RESULT_CANCELED);
261 finish();
262 break;
263 case R.id.share_button:
264 this.binding.shareButton.setEnabled(false);
265 this.binding.shareButton.setText(R.string.please_wait);
266 mHandler.removeCallbacks(mTickExecutor);
267 mHandler.postDelayed(() -> stopRecording(true), 500);
268 break;
269 }
270 }
271}