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