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