diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java index 0c0ee10b24..7607cfc7e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java @@ -57,6 +57,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { private ProgressEventHandler progressEventHandler; private MutableLiveData voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE); private LiveData> voiceNotePlayerViewState; + private VoiceNoteProximityWakeLockManager voiceNoteProximityWakeLockManager; private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback(); @@ -275,6 +276,9 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { } } + cleanUpOldProximityWakeLockManager(); + voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController); + mediaController.registerCallback(mediaControllerCompatCallback); mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState()); @@ -282,6 +286,26 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { Log.w(TAG, "onConnected: Failed to set media controller", e); } } + + @Override + public void onConnectionSuspended() { + Log.d(TAG, "Voice note MediaBrowser connection suspended."); + cleanUpOldProximityWakeLockManager(); + } + + @Override + public void onConnectionFailed() { + Log.d(TAG, "Voice note MediaBrowser connection failed."); + cleanUpOldProximityWakeLockManager(); + } + + private void cleanUpOldProximityWakeLockManager() { + if (voiceNoteProximityWakeLockManager != null) { + Log.d(TAG, "Session reconnected, cleaning up old wake lock manager"); + voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease(); + voiceNoteProximityWakeLockManager = null; + } + } } private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt index 5c99e2dcde..af016e0d98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt @@ -1,15 +1,20 @@ package org.thoughtcrime.securesms.components.voice +import android.media.AudioManager import android.os.Bundle import android.os.ResultReceiver +import com.google.android.exoplayer2.C import com.google.android.exoplayer2.PlaybackParameters import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController +import com.google.android.exoplayer2.util.Util class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters) : DefaultPlaybackController() { override fun getCommands(): Array { - return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) + return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) } override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) { @@ -18,6 +23,24 @@ class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: Voice player.playbackParameters = PlaybackParameters(speed) voiceNotePlaybackParameters.setSpeed(speed) + } else if (command == VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) { + val newStreamType: Int = extras?.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) ?: AudioManager.STREAM_MUSIC + + val currentStreamType = Util.getStreamTypeForAudioUsage((player as SimpleExoPlayer).audioAttributes.usage) + if (newStreamType != currentStreamType) { + val attributes = when (newStreamType) { + AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build() + AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build() + else -> throw AssertionError() + } + + player.playWhenReady = false + player.audioAttributes = attributes + + if (newStreamType == AudioManager.STREAM_VOICE_CALL) { + player.playWhenReady = true + } + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java index 33aa38747e..e5bb930f3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -56,6 +56,7 @@ import java.util.List; public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed"; + public static final String ACTION_SET_AUDIO_STREAM = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.set_audio_stream"; private static final String TAG = Log.tag(VoiceNotePlaybackService.class); private static final String EMPTY_ROOT_ID = "empty-root-id"; @@ -76,7 +77,6 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { private VoiceNoteNotificationManager voiceNoteNotificationManager; private VoiceNoteQueueDataAdapter queueDataAdapter; private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer; - private VoiceNoteProximityManager voiceNoteProximityManager; private boolean isForegroundService; private VoiceNotePlaybackParameters voiceNotePlaybackParameters; @@ -109,7 +109,6 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this); voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory, voiceNotePlaybackParameters); - voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter); mediaSession.setPlaybackState(stateBuilder.build()); @@ -171,15 +170,12 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { if (!playWhenReady) { stopForeground(false); becomingNoisyReceiver.unregister(); - voiceNoteProximityManager.onPlayerEnded(); } else { sendViewedReceiptForCurrentWindowIndex(); becomingNoisyReceiver.register(); - voiceNoteProximityManager.onPlayerReady(); } break; default: - voiceNoteProximityManager.onPlayerEnded(); becomingNoisyReceiver.unregister(); voiceNoteNotificationManager.hideNotification(); } @@ -219,7 +215,6 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { @Override public void onPlayerError(ExoPlaybackException error) { Log.w(TAG, "ExoPlayer error occurred:", error); - voiceNoteProximityManager.onPlayerError(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityManager.java deleted file mode 100644 index 5ca43b05ff..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityManager.java +++ /dev/null @@ -1,146 +0,0 @@ -package org.thoughtcrime.securesms.components.voice; - -import android.content.Context; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import android.media.AudioManager; -import android.os.Build; -import android.os.PowerManager; -import android.support.v4.media.MediaDescriptionCompat; - -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.audio.AudioAttributes; -import com.google.android.exoplayer2.util.Util; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.util.ServiceUtil; - -import java.util.concurrent.TimeUnit; - -class VoiceNoteProximityManager implements SensorEventListener { - - private static final String TAG = Log.tag(VoiceNoteProximityManager.class); - - private static final float PROXIMITY_THRESHOLD = 5f; - - private final SimpleExoPlayer player; - private final AudioManager audioManager; - private final SensorManager sensorManager; - private final Sensor proximitySensor; - private final PowerManager.WakeLock wakeLock; - private final VoiceNoteQueueDataAdapter queueDataAdapter; - - private long startTime; - - VoiceNoteProximityManager(@NonNull Context context, - @NonNull SimpleExoPlayer player, - @NonNull VoiceNoteQueueDataAdapter queueDataAdapter) - { - this.player = player; - this.audioManager = ServiceUtil.getAudioManager(context); - this.sensorManager = ServiceUtil.getSensorManager(context); - this.proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); - this.queueDataAdapter = queueDataAdapter; - - if (Build.VERSION.SDK_INT >= 21) { - this.wakeLock = ServiceUtil.getPowerManager(context).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG); - } else { - this.wakeLock = null; - } - } - - void onPlayerReady() { - startTime = System.currentTimeMillis(); - sensorManager.registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL); - } - - void onPlayerEnded() { - sensorManager.unregisterListener(this); - - if (wakeLock != null && wakeLock.isHeld() && Build.VERSION.SDK_INT >= 21) { - wakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); - } - } - - void onPlayerError() { - onPlayerEnded(); - } - - @Override - public void onSensorChanged(SensorEvent event) { - if (event.sensor.getType() != Sensor.TYPE_PROXIMITY || player.getPlaybackState() != Player.STATE_READY) { - return; - } - - final int desiredStreamType; - if (event.values[0] < PROXIMITY_THRESHOLD && event.values[0] != proximitySensor.getMaximumRange()) { - desiredStreamType = AudioManager.STREAM_VOICE_CALL; - } else { - desiredStreamType = AudioManager.STREAM_MUSIC; - } - - final int currentStreamType = Util.getStreamTypeForAudioUsage(player.getAudioAttributes().usage); - - final long threadId; - final int windowIndex = player.getCurrentWindowIndex(); - - if (queueDataAdapter.isEmpty() || windowIndex == C.INDEX_UNSET) { - threadId = -1; - } else { - MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(windowIndex); - - if (mediaDescriptionCompat.getExtras() == null) { - threadId = -1; - } else { - threadId = mediaDescriptionCompat.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID, -1); - } - } - - if (desiredStreamType == AudioManager.STREAM_VOICE_CALL && - desiredStreamType != currentStreamType && - !audioManager.isWiredHeadsetOn() && - threadId != -1 && - ApplicationDependencies.getMessageNotifier().getVisibleThread() == threadId) - { - if (wakeLock != null && !wakeLock.isHeld()) { - wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)); - } - - player.setPlayWhenReady(false); - player.setAudioAttributes(new AudioAttributes.Builder() - .setContentType(C.CONTENT_TYPE_SPEECH) - .setUsage(C.USAGE_VOICE_COMMUNICATION) - .build()); - player.setPlayWhenReady(true); - - startTime = System.currentTimeMillis(); - } else if (desiredStreamType == AudioManager.STREAM_MUSIC && - desiredStreamType != currentStreamType && - System.currentTimeMillis() - startTime > 500) - { - if (wakeLock != null) { - if (wakeLock.isHeld()) { - wakeLock.release(); - } - - player.setPlayWhenReady(false); - player.setAudioAttributes(new AudioAttributes.Builder() - .setContentType(C.CONTENT_TYPE_MUSIC) - .setUsage(C.USAGE_MEDIA) - .build(), - true); - } - } - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) { - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityWakeLockManager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityWakeLockManager.kt new file mode 100644 index 0000000000..e87059ebdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityWakeLockManager.kt @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.components.voice + +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.util.ServiceUtil +import java.util.concurrent.TimeUnit + +private val TAG = Log.tag(VoiceNoteProximityWakeLockManager::class.java) +private const val PROXIMITY_THRESHOLD = 5f + +/** + * Manages the WakeLock while a VoiceNote is playing back in the target activity. + */ +class VoiceNoteProximityWakeLockManager( + private val activity: AppCompatActivity, + private val mediaController: MediaControllerCompat +) : DefaultLifecycleObserver { + + private val wakeLock: PowerManager.WakeLock? = if (Build.VERSION.SDK_INT >= 21) { + ServiceUtil.getPowerManager(activity).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG) + } else { + null + } + + private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity) + private val proximitySensor: Sensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY) + + private val mediaControllerCallback = MediaControllerCallback() + private val hardwareSensorEventListener = HardwareSensorEventListener() + + private var startTime: Long = -1 + + init { + activity.lifecycle.addObserver(this) + } + + override fun onResume(owner: LifecycleOwner) { + mediaController.registerCallback(mediaControllerCallback) + } + + override fun onPause(owner: LifecycleOwner) { + unregisterCallbacksAndRelease() + } + + fun unregisterCallbacksAndRelease() { + mediaController.unregisterCallback(mediaControllerCallback) + cleanUpWakeLock() + } + + private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + + private fun isPlayerActive() = mediaController.playbackState.state == PlaybackStateCompat.STATE_BUFFERING || + mediaController.playbackState.state == PlaybackStateCompat.STATE_PLAYING + + private fun cleanUpWakeLock() { + startTime = -1L + sensorManager.unregisterListener(hardwareSensorEventListener) + + if (wakeLock?.isHeld == true) { + wakeLock.release() + } + + sendNewStreamTypeToPlayer(AudioManager.STREAM_MUSIC) + } + + private fun sendNewStreamTypeToPlayer(newStreamType: Int) { + val params = Bundle() + params.putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, newStreamType) + mediaController.sendCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, params, null) + } + + inner class MediaControllerCallback : MediaControllerCompat.Callback() { + override fun onPlaybackStateChanged(state: PlaybackStateCompat) { + if (!isActivityResumed()) { + return + } + + if (isPlayerActive()) { + if (startTime == -1L) { + startTime = System.currentTimeMillis() + sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL) + } + } else { + cleanUpWakeLock() + } + } + } + + inner class HardwareSensorEventListener : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + if (startTime == -1L || + System.currentTimeMillis() - startTime <= 500 || + !isActivityResumed() || + !isPlayerActive() || + event.sensor.type != Sensor.TYPE_PROXIMITY + ) { + return + } + + val newStreamType = if (event.values[0] < PROXIMITY_THRESHOLD && event.values[0] != proximitySensor.maximumRange) { + AudioManager.STREAM_VOICE_CALL + } else { + AudioManager.STREAM_MUSIC + } + + sendNewStreamTypeToPlayer(newStreamType) + + if (newStreamType == AudioManager.STREAM_VOICE_CALL) { + if (wakeLock?.isHeld == false) { + wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) + } + + startTime = System.currentTimeMillis() + } else { + if (wakeLock?.isHeld == true) { + wakeLock.release() + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackControllerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackControllerTest.kt new file mode 100644 index 0000000000..56400bc29c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackControllerTest.kt @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.components.voice + +import android.app.Application +import android.media.AudioManager +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.audio.AudioAttributes +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.Mockito.any +import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class VoiceNotePlaybackControllerTest { + + private val mediaSessionCompat = mock(MediaSessionCompat::class.java) + private val playbackParameters = VoiceNotePlaybackParameters(mediaSessionCompat) + private val testSubject = VoiceNotePlaybackController(playbackParameters) + private val mediaAudioAttributes = AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build() + private val callAudioAttributes = AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build() + private val player: SimpleExoPlayer = mock(SimpleExoPlayer::class.java) + + @Test + fun `When I getCommands, then I expect PLAYBACK_SPEED and AUDIO_STREAM`() { + assertArrayEquals(arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM), testSubject.commands) + } + + @Test + fun `Given stream is media, When I onCommand for voice, then I expect the stream to switch to voice and continue playback`() { + // GIVEN + `when`(player.audioAttributes).thenReturn(mediaAudioAttributes) + + val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM + val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_VOICE_CALL) } + val expected = callAudioAttributes + + // WHEN + testSubject.onCommand(player, command, extras, null) + + // THEN + verify(player).playWhenReady = false + verify(player).audioAttributes = expected + verify(player).playWhenReady = true + } + + @Test + fun `Given stream is voice, When I onCommand for media, then I expect the stream to switch to media and pause playback`() { + // GIVEN + `when`(player.audioAttributes).thenReturn(callAudioAttributes) + + val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM + val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) } + val expected = mediaAudioAttributes + + // WHEN + testSubject.onCommand(player, command, extras, null) + + // THEN + verify(player).playWhenReady = false + verify(player).audioAttributes = expected + verify(player, Mockito.never()).playWhenReady = true + } + + @Test + fun `Given stream is voice, When I onCommand for voice, then I expect no change`() { + // GIVEN + `when`(player.audioAttributes).thenReturn(callAudioAttributes) + + val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM + val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_VOICE_CALL) } + + // WHEN + testSubject.onCommand(player, command, extras, null) + + // THEN + verify(player, Mockito.never()).playWhenReady = anyBoolean() + verify(player, Mockito.never()).audioAttributes = any() + } + + @Test + fun `Given stream is media, When I onCommand for media, then I expect no change`() { + // GIVEN + `when`(player.audioAttributes).thenReturn(mediaAudioAttributes) + + val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM + val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) } + + // WHEN + testSubject.onCommand(player, command, extras, null) + + // THEN + verify(player, Mockito.never()).playWhenReady = anyBoolean() + verify(player, Mockito.never()).audioAttributes = any() + } +}