1package eu.siacs.conversations.ui.service;
2
3import android.Manifest;
4import android.content.Context;
5import android.content.pm.PackageManager;
6import android.content.res.ColorStateList;
7import android.hardware.Sensor;
8import android.hardware.SensorEvent;
9import android.hardware.SensorEventListener;
10import android.hardware.SensorManager;
11import android.media.AudioManager;
12import android.os.Build;
13import android.os.Handler;
14import android.os.PowerManager;
15import android.util.Log;
16import android.view.View;
17import android.widget.ImageButton;
18import android.widget.RelativeLayout;
19import android.widget.SeekBar;
20import android.widget.TextView;
21
22import androidx.core.app.ActivityCompat;
23import androidx.core.content.ContextCompat;
24
25import com.google.common.primitives.Ints;
26
27import eu.siacs.conversations.Config;
28import eu.siacs.conversations.R;
29import eu.siacs.conversations.entities.Message;
30import eu.siacs.conversations.services.MediaPlayer;
31import eu.siacs.conversations.ui.ConversationsActivity;
32import eu.siacs.conversations.ui.adapter.MessageAdapter;
33import eu.siacs.conversations.ui.util.PendingItem;
34import eu.siacs.conversations.utils.TimeFrameUtils;
35import eu.siacs.conversations.utils.WeakReferenceSet;
36
37import java.lang.ref.WeakReference;
38import java.util.concurrent.ExecutorService;
39import java.util.concurrent.Executors;
40
41public class AudioPlayer
42 implements View.OnClickListener,
43 MediaPlayer.OnCompletionListener,
44 SeekBar.OnSeekBarChangeListener,
45 Runnable,
46 SensorEventListener {
47
48 private static final int REFRESH_INTERVAL = 250;
49 private static final Object LOCK = new Object();
50 private static MediaPlayer player = null;
51 private static Message currentlyPlayingMessage = null;
52 private static PowerManager.WakeLock wakeLock;
53 private final MessageAdapter messageAdapter;
54 private final WeakReferenceSet<RelativeLayout> audioPlayerLayouts = new WeakReferenceSet<>();
55 private final SensorManager sensorManager;
56 private final Sensor proximitySensor;
57 private final PendingItem<WeakReference<ImageButton>> pendingOnClickView = new PendingItem<>();
58
59 private final ExecutorService executor = Executors.newSingleThreadExecutor();
60
61 private final Handler handler = new Handler();
62
63 public AudioPlayer(MessageAdapter adapter) {
64 final Context context = adapter.getContext();
65 this.messageAdapter = adapter;
66 this.sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
67 this.proximitySensor =
68 this.sensorManager == null
69 ? null
70 : this.sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
71 initializeProximityWakeLock(context);
72 synchronized (AudioPlayer.LOCK) {
73 if (AudioPlayer.player != null) {
74 AudioPlayer.player.setOnCompletionListener(this);
75 if (AudioPlayer.player.isPlaying() && sensorManager != null) {
76 sensorManager.registerListener(
77 this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
78 }
79 }
80 }
81 }
82
83 private static String formatTime(final int ms) {
84 return TimeFrameUtils.formatElapsedTime(ms,false);
85 }
86
87 private void initializeProximityWakeLock(Context context) {
88 synchronized (AudioPlayer.LOCK) {
89 if (AudioPlayer.wakeLock == null) {
90 final PowerManager powerManager =
91 (PowerManager) context.getSystemService(Context.POWER_SERVICE);
92 AudioPlayer.wakeLock =
93 powerManager == null
94 ? null
95 : powerManager.newWakeLock(
96 PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
97 AudioPlayer.class.getSimpleName());
98 AudioPlayer.wakeLock.setReferenceCounted(false);
99 }
100 }
101 }
102
103 public void init(RelativeLayout audioPlayer, Message message) {
104 synchronized (AudioPlayer.LOCK) {
105 audioPlayer.setTag(message);
106 if (init(ViewHolder.get(audioPlayer), message)) {
107 this.audioPlayerLayouts.addWeakReferenceTo(audioPlayer);
108 executor.execute(() -> this.stopRefresher(true));
109 } else {
110 this.audioPlayerLayouts.removeWeakReferenceTo(audioPlayer);
111 }
112 }
113 }
114
115 private boolean init(final ViewHolder viewHolder, final Message message) {
116 MessageAdapter.setTextColor(viewHolder.runtime, viewHolder.bubbleColor);
117 viewHolder.progress.setOnSeekBarChangeListener(this);
118 final ColorStateList color =
119 MessageAdapter.bubbleToOnSurfaceColorStateList(
120 viewHolder.progress, viewHolder.bubbleColor);
121 viewHolder.progress.setThumbTintList(color);
122 viewHolder.progress.setProgressTintList(color);
123 viewHolder.playPause.setOnClickListener(this);
124 final Context context = viewHolder.playPause.getContext();
125 if (message == currentlyPlayingMessage) {
126 if (AudioPlayer.player != null && AudioPlayer.player.isPlaying()) {
127 viewHolder.playPause.setImageResource(R.drawable.ic_pause_24dp);
128 MessageAdapter.setImageTint(viewHolder.playPause, viewHolder.bubbleColor);
129 viewHolder.playPause.setContentDescription(context.getString(R.string.pause_audio));
130 viewHolder.progress.setEnabled(true);
131 } else {
132 viewHolder.playPause.setContentDescription(context.getString(R.string.play_audio));
133 viewHolder.playPause.setImageResource(R.drawable.ic_play_arrow_24dp);
134 MessageAdapter.setImageTint(viewHolder.playPause, viewHolder.bubbleColor);
135 viewHolder.progress.setEnabled(false);
136 }
137 return true;
138 } else {
139 viewHolder.playPause.setImageResource(R.drawable.ic_play_arrow_24dp);
140 MessageAdapter.setImageTint(viewHolder.playPause, viewHolder.bubbleColor);
141 viewHolder.playPause.setContentDescription(context.getString(R.string.play_audio));
142 viewHolder.runtime.setText(formatTime(message.getFileParams().runtime));
143 viewHolder.progress.setProgress(0);
144 viewHolder.progress.setEnabled(false);
145 return false;
146 }
147 }
148
149 @Override
150 public synchronized void onClick(View v) {
151 if (v.getId() == R.id.play_pause) {
152 synchronized (LOCK) {
153 startStop((ImageButton) v);
154 }
155 }
156 }
157
158 private void startStop(ImageButton playPause) {
159 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
160 && ContextCompat.checkSelfPermission(
161 messageAdapter.getActivity(),
162 Manifest.permission.WRITE_EXTERNAL_STORAGE)
163 != PackageManager.PERMISSION_GRANTED) {
164 pendingOnClickView.push(new WeakReference<>(playPause));
165 ActivityCompat.requestPermissions(
166 messageAdapter.getActivity(),
167 new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
168 ConversationsActivity.REQUEST_PLAY_PAUSE);
169 return;
170 }
171 initializeProximityWakeLock(playPause.getContext());
172 final RelativeLayout audioPlayer = (RelativeLayout) playPause.getParent();
173 final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
174 final Message message = (Message) audioPlayer.getTag();
175 if (startStop(viewHolder, message)) {
176 this.audioPlayerLayouts.clear();
177 this.audioPlayerLayouts.addWeakReferenceTo(audioPlayer);
178 stopRefresher(true);
179 }
180 }
181
182 private boolean playPauseCurrent(final ViewHolder viewHolder) {
183 final Context context = viewHolder.playPause.getContext();
184 if (player.isPlaying()) {
185 viewHolder.progress.setEnabled(false);
186 player.pause();
187 messageAdapter.flagScreenOff();
188 releaseProximityWakeLock();
189 viewHolder.playPause.setImageResource(R.drawable.ic_play_arrow_24dp);
190 MessageAdapter.setImageTint(viewHolder.playPause, viewHolder.bubbleColor);
191 viewHolder.playPause.setContentDescription(context.getString(R.string.play_audio));
192 } else {
193 viewHolder.progress.setEnabled(true);
194 player.start();
195 messageAdapter.flagScreenOn();
196 acquireProximityWakeLock();
197 this.stopRefresher(true);
198 viewHolder.playPause.setImageResource(R.drawable.ic_pause_24dp);
199 MessageAdapter.setImageTint(viewHolder.playPause, viewHolder.bubbleColor);
200 viewHolder.playPause.setContentDescription(context.getString(R.string.pause_audio));
201 }
202 return false;
203 }
204
205 private void play(ViewHolder viewHolder, Message message, boolean earpiece, double progress) {
206 if (play(viewHolder, message, earpiece)) {
207 AudioPlayer.player.seekTo((int) (AudioPlayer.player.getDuration() * progress));
208 }
209 }
210
211 private boolean play(ViewHolder viewHolder, Message message, boolean earpiece) {
212 AudioPlayer.player = new MediaPlayer();
213 try {
214 AudioPlayer.currentlyPlayingMessage = message;
215 AudioPlayer.player.setAudioStreamType(
216 earpiece ? AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC);
217 AudioPlayer.player.setDataSource(
218 messageAdapter.getFileBackend().getFile(message).getAbsolutePath());
219 AudioPlayer.player.setOnCompletionListener(this);
220 AudioPlayer.player.prepare();
221 AudioPlayer.player.start();
222 messageAdapter.flagScreenOn();
223 acquireProximityWakeLock();
224 viewHolder.progress.setEnabled(true);
225 viewHolder.playPause.setImageResource(R.drawable.ic_pause_24dp);
226 MessageAdapter.setImageTint(viewHolder.playPause, viewHolder.bubbleColor);
227 viewHolder.playPause.setContentDescription(
228 viewHolder.playPause.getContext().getString(R.string.pause_audio));
229 sensorManager.registerListener(
230 this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
231 return true;
232 } catch (final Exception e) {
233 messageAdapter.flagScreenOff();
234 releaseProximityWakeLock();
235 AudioPlayer.currentlyPlayingMessage = null;
236 sensorManager.unregisterListener(this);
237 return false;
238 }
239 }
240
241 public void startStopPending() {
242 WeakReference<ImageButton> reference = pendingOnClickView.pop();
243 if (reference != null) {
244 ImageButton imageButton = reference.get();
245 if (imageButton != null) {
246 startStop(imageButton);
247 }
248 }
249 }
250
251 private boolean startStop(ViewHolder viewHolder, Message message) {
252 if (message == currentlyPlayingMessage && player != null) {
253 return playPauseCurrent(viewHolder);
254 }
255 if (AudioPlayer.player != null) {
256 stopCurrent();
257 }
258 return play(viewHolder, message, false);
259 }
260
261 private void stopCurrent() {
262 if (AudioPlayer.player.isPlaying()) {
263 AudioPlayer.player.stop();
264 }
265 AudioPlayer.player.release();
266 messageAdapter.flagScreenOff();
267 releaseProximityWakeLock();
268 AudioPlayer.player = null;
269 resetPlayerUi();
270 }
271
272 private void resetPlayerUi() {
273 for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
274 resetPlayerUi(audioPlayer.get());
275 }
276 }
277
278 private void resetPlayerUi(final RelativeLayout audioPlayer) {
279 if (audioPlayer == null) {
280 return;
281 }
282 final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
283 final Message message = (Message) audioPlayer.getTag();
284 viewHolder.playPause.setContentDescription(
285 viewHolder.playPause.getContext().getString(R.string.play_audio));
286 viewHolder.playPause.setImageResource(R.drawable.ic_play_arrow_24dp);
287 MessageAdapter.setImageTint(viewHolder.playPause, viewHolder.bubbleColor);
288 if (message != null) {
289 viewHolder.runtime.setText(formatTime(message.getFileParams().runtime));
290 }
291 viewHolder.progress.setProgress(0);
292 viewHolder.progress.setEnabled(false);
293 }
294
295 @Override
296 public void onCompletion(android.media.MediaPlayer mediaPlayer) {
297 synchronized (AudioPlayer.LOCK) {
298 this.stopRefresher(false);
299 if (AudioPlayer.player == mediaPlayer) {
300 AudioPlayer.currentlyPlayingMessage = null;
301 AudioPlayer.player = null;
302 }
303 mediaPlayer.release();
304 messageAdapter.flagScreenOff();
305 releaseProximityWakeLock();
306 resetPlayerUi();
307 sensorManager.unregisterListener(this);
308 }
309 }
310
311 @Override
312 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
313 synchronized (AudioPlayer.LOCK) {
314 final RelativeLayout audioPlayer = (RelativeLayout) seekBar.getParent();
315 final Message message = (Message) audioPlayer.getTag();
316 final MediaPlayer player = AudioPlayer.player;
317 if (fromUser && message == AudioPlayer.currentlyPlayingMessage && player != null) {
318 float percent = progress / 100f;
319 int duration = player.getDuration();
320 int seekTo = Math.round(duration * percent);
321 player.seekTo(seekTo);
322 }
323 }
324 }
325
326 @Override
327 public void onStartTrackingTouch(SeekBar seekBar) {}
328
329 @Override
330 public void onStopTrackingTouch(SeekBar seekBar) {}
331
332 public void stop() {
333 synchronized (AudioPlayer.LOCK) {
334 stopRefresher(false);
335 if (AudioPlayer.player != null) {
336 stopCurrent();
337 }
338 AudioPlayer.currentlyPlayingMessage = null;
339 sensorManager.unregisterListener(this);
340 if (wakeLock != null && wakeLock.isHeld()) {
341 wakeLock.release();
342 }
343 wakeLock = null;
344 }
345 }
346
347 private void stopRefresher(boolean runOnceMore) {
348 this.handler.removeCallbacks(this);
349 if (runOnceMore) {
350 this.handler.post(this);
351 }
352 }
353
354 public void unregisterListener() {
355 if (sensorManager != null) {
356 sensorManager.unregisterListener(this);
357 }
358 }
359
360 @Override
361 public void run() {
362 synchronized (AudioPlayer.LOCK) {
363 if (AudioPlayer.player != null) {
364 boolean renew = false;
365 final int current = player.getCurrentPosition();
366 final int duration = player.getDuration();
367 for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
368 renew |= refreshAudioPlayer(audioPlayer.get(), current, duration);
369 }
370 if (renew && AudioPlayer.player.isPlaying()) {
371 handler.postDelayed(this, REFRESH_INTERVAL);
372 }
373 }
374 }
375 }
376
377 private boolean refreshAudioPlayer(RelativeLayout audioPlayer, int current, int duration) {
378 if (audioPlayer == null || audioPlayer.getVisibility() != View.VISIBLE) {
379 return false;
380 }
381 final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
382 if (duration <= 0) {
383 viewHolder.progress.setProgress(100);
384 } else {
385 final var progress = current * 100L / duration;
386 viewHolder.progress.setProgress(Math.min(Ints.saturatedCast(progress), 100));
387 }
388 viewHolder.runtime.setText(
389 String.format("%s / %s", formatTime(current), formatTime(duration)));
390 return true;
391 }
392
393 @Override
394 public void onSensorChanged(SensorEvent event) {
395 if (event.sensor.getType() != Sensor.TYPE_PROXIMITY) {
396 return;
397 }
398 if (AudioPlayer.player == null || !AudioPlayer.player.isPlaying()) {
399 return;
400 }
401 final int streamType;
402 if (event.values[0] < 5f && event.values[0] != proximitySensor.getMaximumRange()) {
403 streamType = AudioManager.STREAM_VOICE_CALL;
404 } else {
405 streamType = AudioManager.STREAM_MUSIC;
406 }
407 messageAdapter.setVolumeControl(streamType);
408 double position = AudioPlayer.player.getCurrentPosition();
409 double duration = AudioPlayer.player.getDuration();
410 double progress = position / duration;
411 if (AudioPlayer.player.getAudioStreamType() != streamType) {
412 synchronized (AudioPlayer.LOCK) {
413 AudioPlayer.player.stop();
414 AudioPlayer.player.release();
415 AudioPlayer.player = null;
416 try {
417 ViewHolder currentViewHolder = getCurrentViewHolder();
418 if (currentViewHolder != null) {
419 play(
420 currentViewHolder,
421 currentlyPlayingMessage,
422 streamType == AudioManager.STREAM_VOICE_CALL,
423 progress);
424 }
425 } catch (Exception e) {
426 Log.w(Config.LOGTAG, e);
427 }
428 }
429 }
430 }
431
432 @Override
433 public void onAccuracyChanged(Sensor sensor, int i) {}
434
435 private void acquireProximityWakeLock() {
436 synchronized (AudioPlayer.LOCK) {
437 if (wakeLock != null) {
438 wakeLock.acquire();
439 }
440 }
441 }
442
443 private void releaseProximityWakeLock() {
444 synchronized (AudioPlayer.LOCK) {
445 if (wakeLock != null && wakeLock.isHeld()) {
446 wakeLock.release();
447 }
448 }
449 messageAdapter.setVolumeControl(AudioManager.STREAM_MUSIC);
450 }
451
452 private ViewHolder getCurrentViewHolder() {
453 for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
454 final Message message = (Message) audioPlayer.get().getTag();
455 if (message == currentlyPlayingMessage) {
456 return ViewHolder.get(audioPlayer.get());
457 }
458 }
459 return null;
460 }
461
462 public static class ViewHolder {
463 private TextView runtime;
464 private SeekBar progress;
465 private ImageButton playPause;
466 private MessageAdapter.BubbleColor bubbleColor = MessageAdapter.BubbleColor.SURFACE;
467
468 public static ViewHolder get(final RelativeLayout audioPlayer) {
469 final var existingViewHolder =
470 (ViewHolder) audioPlayer.getTag(R.id.TAG_AUDIO_PLAYER_VIEW_HOLDER);
471 if (existingViewHolder != null) {
472 return existingViewHolder;
473 }
474 final ViewHolder viewHolder = new ViewHolder();
475 viewHolder.runtime = audioPlayer.findViewById(R.id.runtime);
476 viewHolder.progress = audioPlayer.findViewById(R.id.progress);
477 viewHolder.playPause = audioPlayer.findViewById(R.id.play_pause);
478 audioPlayer.setTag(R.id.TAG_AUDIO_PLAYER_VIEW_HOLDER, viewHolder);
479 return viewHolder;
480 }
481
482 public void setBubbleColor(final MessageAdapter.BubbleColor bubbleColor) {
483 this.bubbleColor = bubbleColor;
484 }
485 }
486}