diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 4ef0db8aa4..c425952ed1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -17,8 +17,6 @@ package org.thoughtcrime.securesms; -import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE; - import android.Manifest; import android.annotation.SuppressLint; import android.app.PictureInPictureParams; @@ -79,6 +77,7 @@ import org.thoughtcrime.securesms.util.ThrottledDebouncer; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.signalservice.api.messages.calls.HangupMessage; @@ -86,6 +85,8 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE; + public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback { private static final String TAG = Log.tag(WebRtcCallActivity.class); @@ -366,15 +367,15 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan } private void handleSetAudioHandset() { - ApplicationDependencies.getSignalCallManager().setAudioSpeaker(false); + ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.EARPIECE); } private void handleSetAudioSpeaker() { - ApplicationDependencies.getSignalCallManager().setAudioSpeaker(true); + ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE); } private void handleSetAudioBluetooth() { - ApplicationDependencies.getSignalCallManager().setAudioBluetooth(true); + ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.BLUETOOTH); } private void handleSetMuteAudio(boolean enabled) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt index a4b1018c74..43bf7d3c28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt @@ -221,7 +221,7 @@ data class CallParticipantsState( localRenderState = localRenderState, showVideoForOutgoing = newShowVideoForOutgoing, remoteDevicesCount = webRtcViewModel.remoteDevicesCount, - ringGroup = webRtcViewModel.shouldRingGroup(), + ringGroup = webRtcViewModel.ringGroup, isInOutgoingRingingMode = isInOutgoingRingingMode, ringerRecipient = webRtcViewModel.ringerRecipient ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java index c099d9bfe7..23ac9d94c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.components.webrtc; import android.content.Context; -import android.media.AudioManager; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; @@ -10,11 +9,9 @@ import androidx.core.util.Consumer; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.identity.IdentityRecordList; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.ServiceUtil; import java.util.Collections; import java.util.List; @@ -22,21 +19,9 @@ import java.util.List; class WebRtcCallRepository { private final Context context; - private final AudioManager audioManager; WebRtcCallRepository(@NonNull Context context) { this.context = context; - this.audioManager = ServiceUtil.getAudioManager(ApplicationDependencies.getApplication()); - } - - @NonNull WebRtcAudioOutput getAudioOutput() { - if (audioManager.isBluetoothScoOn()) { - return WebRtcAudioOutput.HEADSET; - } else if (audioManager.isSpeakerphoneOn()) { - return WebRtcAudioOutput.SPEAKER; - } else { - return WebRtcAudioOutput.HANDSET; - } } @WorkerThread diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java index dcbad0be93..0219d8b531 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -19,7 +19,6 @@ import com.annimon.stream.Stream; import org.signal.core.util.ThreadUtil; import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor; import org.thoughtcrime.securesms.components.sensors.Orientation; -import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.model.IdentityRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.CallParticipant; @@ -35,11 +34,13 @@ import org.thoughtcrime.securesms.util.DefaultValueLiveData; import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Set; public class WebRtcCallViewModel extends ViewModel { @@ -252,9 +253,9 @@ public class WebRtcCallViewModel extends ViewModel { webRtcViewModel.isRemoteVideoEnabled(), webRtcViewModel.isRemoteVideoOffer(), localParticipant.isMoreThanOneCameraAvailable(), - webRtcViewModel.isBluetoothAvailable(), Util.hasItems(webRtcViewModel.getRemoteParticipants()), - repository.getAudioOutput(), + webRtcViewModel.getActiveDevice(), + webRtcViewModel.getAvailableDevices(), webRtcViewModel.getRemoteDevicesCount().orElse(0), webRtcViewModel.getParticipantLimit()); @@ -314,9 +315,9 @@ public class WebRtcCallViewModel extends ViewModel { boolean isRemoteVideoEnabled, boolean isRemoteVideoOffer, boolean isMoreThanOneCameraAvailable, - boolean isBluetoothAvailable, boolean hasAtLeastOneRemote, - @NonNull WebRtcAudioOutput audioOutput, + @NonNull SignalAudioManager.AudioDevice activeDevice, + @NonNull Set availableDevices, long remoteDevicesCount, @Nullable Long participantLimit) { @@ -373,14 +374,14 @@ public class WebRtcCallViewModel extends ViewModel { webRtcControls.setValue(new WebRtcControls(isLocalVideoEnabled, isRemoteVideoEnabled || isRemoteVideoOffer, isMoreThanOneCameraAvailable, - isBluetoothAvailable, Boolean.TRUE.equals(isInPipMode.getValue()), hasAtLeastOneRemote, callState, groupCallState, - audioOutput, participantLimit, - WebRtcControls.FoldableState.flat())); + WebRtcControls.FoldableState.flat(), + activeDevice, + availableDevices)); } private @NonNull WebRtcControls updateControlsFoldableState(@NonNull WebRtcControls.FoldableState foldableState, @NonNull WebRtcControls controls) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java index ab75a810cb..91d0443304 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java @@ -9,65 +9,90 @@ import androidx.annotation.StringRes; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; + +import java.util.Set; + +import static java.util.Collections.emptySet; public final class WebRtcControls { public static final WebRtcControls NONE = new WebRtcControls(); - public static final WebRtcControls PIP = new WebRtcControls(false, false, false, false, true, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat()); + public static final WebRtcControls PIP = new WebRtcControls(false, + false, + false, + true, + false, + CallState.NONE, + GroupCallState.NONE, + null, + FoldableState.flat(), + SignalAudioManager.AudioDevice.NONE, + emptySet()); - private final boolean isRemoteVideoEnabled; - private final boolean isLocalVideoEnabled; - private final boolean isMoreThanOneCameraAvailable; - private final boolean isBluetoothAvailable; - private final boolean isInPipMode; - private final boolean hasAtLeastOneRemote; - private final CallState callState; - private final GroupCallState groupCallState; - private final WebRtcAudioOutput audioOutput; - private final Long participantLimit; - private final FoldableState foldableState; + private final boolean isRemoteVideoEnabled; + private final boolean isLocalVideoEnabled; + private final boolean isMoreThanOneCameraAvailable; + private final boolean isInPipMode; + private final boolean hasAtLeastOneRemote; + private final CallState callState; + private final GroupCallState groupCallState; + private final Long participantLimit; + private final FoldableState foldableState; + private final SignalAudioManager.AudioDevice activeDevice; + private final Set availableDevices; private WebRtcControls() { - this(false, false, false, false, false, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat()); + this(false, + false, + false, + false, + false, + CallState.NONE, + GroupCallState.NONE, + null, + FoldableState.flat(), + SignalAudioManager.AudioDevice.NONE, + emptySet()); } WebRtcControls(boolean isLocalVideoEnabled, boolean isRemoteVideoEnabled, boolean isMoreThanOneCameraAvailable, - boolean isBluetoothAvailable, boolean isInPipMode, boolean hasAtLeastOneRemote, @NonNull CallState callState, @NonNull GroupCallState groupCallState, - @NonNull WebRtcAudioOutput audioOutput, @Nullable Long participantLimit, - @NonNull FoldableState foldableState) + @NonNull FoldableState foldableState, + @NonNull SignalAudioManager.AudioDevice activeDevice, + @NonNull Set availableDevices) { this.isLocalVideoEnabled = isLocalVideoEnabled; this.isRemoteVideoEnabled = isRemoteVideoEnabled; - this.isBluetoothAvailable = isBluetoothAvailable; this.isMoreThanOneCameraAvailable = isMoreThanOneCameraAvailable; this.isInPipMode = isInPipMode; this.hasAtLeastOneRemote = hasAtLeastOneRemote; this.callState = callState; this.groupCallState = groupCallState; - this.audioOutput = audioOutput; this.participantLimit = participantLimit; this.foldableState = foldableState; + this.activeDevice = activeDevice; + this.availableDevices = availableDevices; } public @NonNull WebRtcControls withFoldableState(FoldableState foldableState) { return new WebRtcControls(isLocalVideoEnabled, isRemoteVideoEnabled, isMoreThanOneCameraAvailable, - isBluetoothAvailable, isInPipMode, hasAtLeastOneRemote, callState, groupCallState, - audioOutput, participantLimit, - foldableState); + foldableState, + activeDevice, + availableDevices); } boolean displayErrorControls() { @@ -129,7 +154,7 @@ public final class WebRtcControls { } boolean displayAudioToggle() { - return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothAvailable); + return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || enableHeadsetInAudioToggle()); } boolean displayCameraToggle() { @@ -153,7 +178,7 @@ public final class WebRtcControls { } boolean enableHeadsetInAudioToggle() { - return isBluetoothAvailable; + return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH); } boolean isFadeOutEnabled() { @@ -173,7 +198,14 @@ public final class WebRtcControls { } @NonNull WebRtcAudioOutput getAudioOutput() { - return audioOutput; + switch (activeDevice) { + case SPEAKER_PHONE: + return WebRtcAudioOutput.SPEAKER; + case BLUETOOTH: + return WebRtcAudioOutput.HEADSET; + default: + return WebRtcAudioOutput.HANDSET; + } } boolean showSmallHeader() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index 13fe2ca7bd..d3c26fff54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.util.Hex; import org.thoughtcrime.securesms.util.IasKeyStore; import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat; import org.whispersystems.signalservice.api.KeyBackupService; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; @@ -100,8 +101,9 @@ public class ApplicationDependencies { private static volatile TextSecureSessionStore sessionStore; private static volatile TextSecurePreKeyStore preKeyStore; private static volatile SignalSenderKeyStore senderKeyStore; - private static volatile GiphyMp4Cache giphyMp4Cache; - private static volatile SimpleExoPlayerPool exoPlayerPool; + private static volatile GiphyMp4Cache giphyMp4Cache; + private static volatile SimpleExoPlayerPool exoPlayerPool; + private static volatile AudioManagerCompat audioManagerCompat; @MainThread public static void init(@NonNull Application application, @NonNull Provider provider) { @@ -577,6 +579,17 @@ public class ApplicationDependencies { return exoPlayerPool; } + public static @NonNull AudioManagerCompat getAndroidCallAudioManager() { + if (audioManagerCompat == null) { + synchronized (LOCK) { + if (audioManagerCompat == null) { + audioManagerCompat = provider.provideAndroidCallAudioManager(); + } + } + } + return audioManagerCompat; + } + public interface Provider { @NonNull GroupsV2Operations provideGroupsV2Operations(); @NonNull SignalServiceAccountManager provideSignalServiceAccountManager(); @@ -611,5 +624,6 @@ public class ApplicationDependencies { @NonNull SignalSenderKeyStore provideSenderKeyStore(); @NonNull GiphyMp4Cache provideGiphyMp4Cache(); @NonNull SimpleExoPlayerPool provideExoPlayerPool(); + @NonNull AudioManagerCompat provideAndroidCallAudioManager(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 7cc5a30275..675cecf32f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; @@ -295,6 +296,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr return new SimpleExoPlayerPool(context); } + @Override + public @NonNull AudioManagerCompat provideAndroidCallAudioManager() { + return AudioManagerCompat.create(context); + } + private @NonNull WebSocketFactory provideWebSocketFactory(@NonNull SignalWebSocketHealthMonitor healthMonitor) { return new WebSocketFactory() { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java deleted file mode 100644 index f268bec319..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java +++ /dev/null @@ -1,214 +0,0 @@ -package org.thoughtcrime.securesms.events; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.annimon.stream.OptionalLong; -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; - -import java.util.List; -import java.util.Set; - -public class WebRtcViewModel { - - public enum State { - IDLE, - - // Normal states - CALL_PRE_JOIN, - CALL_INCOMING, - CALL_OUTGOING, - CALL_CONNECTED, - CALL_RINGING, - CALL_BUSY, - CALL_DISCONNECTED, - CALL_NEEDS_PERMISSION, - - // Error states - NETWORK_FAILURE, - RECIPIENT_UNAVAILABLE, - NO_SUCH_USER, - UNTRUSTED_IDENTITY, - - // Multiring Hangup States - CALL_ACCEPTED_ELSEWHERE, - CALL_DECLINED_ELSEWHERE, - CALL_ONGOING_ELSEWHERE; - - public boolean isErrorState() { - return this == NETWORK_FAILURE || - this == RECIPIENT_UNAVAILABLE || - this == NO_SUCH_USER || - this == UNTRUSTED_IDENTITY; - } - - public boolean isPreJoinOrNetworkUnavailable() { - return this == CALL_PRE_JOIN || this == NETWORK_FAILURE; - } - - public boolean isPassedPreJoin() { - return this.ordinal() > CALL_PRE_JOIN.ordinal(); - } - } - - public enum GroupCallState { - IDLE, - RINGING, - DISCONNECTED, - CONNECTING, - RECONNECTING, - CONNECTED, - CONNECTED_AND_JOINING, - CONNECTED_AND_JOINED; - - public boolean isIdle() { - return this == IDLE; - } - - public boolean isNotIdle() { - return this != IDLE; - } - - public boolean isConnected() { - switch (this) { - case CONNECTED: - case CONNECTED_AND_JOINING: - case CONNECTED_AND_JOINED: - return true; - } - - return false; - } - - public boolean isNotIdleOrConnected() { - switch (this) { - case DISCONNECTED: - case CONNECTING: - case RECONNECTING: - return true; - } - - return false; - } - - public boolean isRinging() { - return this == RINGING; - } - } - - private final @NonNull State state; - private final @NonNull GroupCallState groupState; - private final @NonNull Recipient recipient; - - private final boolean isBluetoothAvailable; - private final boolean isRemoteVideoOffer; - private final long callConnectedTime; - - private final CallParticipant localParticipant; - private final List remoteParticipants; - private final Set identityChangedRecipients; - private final OptionalLong remoteDevicesCount; - private final Long participantLimit; - private final boolean ringGroup; - private final Recipient ringerRecipient; - - public WebRtcViewModel(@NonNull WebRtcServiceState state) { - this.state = state.getCallInfoState().getCallState(); - this.groupState = state.getCallInfoState().getGroupCallState(); - this.recipient = state.getCallInfoState().getCallRecipient(); - this.isRemoteVideoOffer = state.getCallSetupState().isRemoteVideoOffer(); - this.isBluetoothAvailable = state.getLocalDeviceState().isBluetoothAvailable(); - this.remoteParticipants = state.getCallInfoState().getRemoteCallParticipants(); - this.identityChangedRecipients = state.getCallInfoState().getIdentityChangedRecipients(); - this.callConnectedTime = state.getCallInfoState().getCallConnectedTime(); - this.remoteDevicesCount = state.getCallInfoState().getRemoteDevicesCount(); - this.participantLimit = state.getCallInfoState().getParticipantLimit(); - this.ringGroup = state.getCallSetupState().shouldRingGroup(); - this.ringerRecipient = state.getCallSetupState().getRingerRecipient(); - this.localParticipant = CallParticipant.createLocal(state.getLocalDeviceState().getCameraState(), - state.getVideoState().getLocalSink() != null ? state.getVideoState().getLocalSink() - : new BroadcastVideoSink(), - state.getLocalDeviceState().isMicrophoneEnabled()); - } - - public @NonNull State getState() { - return state; - } - - public @NonNull GroupCallState getGroupState() { - return groupState; - } - - public @NonNull Recipient getRecipient() { - return recipient; - } - - public boolean isRemoteVideoEnabled() { - return Stream.of(remoteParticipants).anyMatch(CallParticipant::isVideoEnabled) || (groupState.isNotIdle() && remoteParticipants.size() > 1); - } - - public boolean isBluetoothAvailable() { - return isBluetoothAvailable; - } - - public boolean isRemoteVideoOffer() { - return isRemoteVideoOffer; - } - - public long getCallConnectedTime() { - return callConnectedTime; - } - - public @NonNull CallParticipant getLocalParticipant() { - return localParticipant; - } - - public @NonNull List getRemoteParticipants() { - return remoteParticipants; - } - - public @NonNull Set getIdentityChangedParticipants() { - return identityChangedRecipients; - } - - public OptionalLong getRemoteDevicesCount() { - return remoteDevicesCount; - } - - public boolean areRemoteDevicesInCall() { - return remoteDevicesCount.isPresent() && remoteDevicesCount.getAsLong() > 0; - } - - public @Nullable Long getParticipantLimit() { - return participantLimit; - } - - public boolean shouldRingGroup() { - return ringGroup; - } - - public @NonNull Recipient getRingerRecipient() { - return ringerRecipient; - } - - @Override - public @NonNull String toString() { - return "WebRtcViewModel{" + - "state=" + state + - ", recipient=" + recipient.getId() + - ", isBluetoothAvailable=" + isBluetoothAvailable + - ", isRemoteVideoOffer=" + isRemoteVideoOffer + - ", callConnectedTime=" + callConnectedTime + - ", localParticipant=" + localParticipant + - ", remoteParticipants=" + remoteParticipants + - ", identityChangedRecipients=" + identityChangedRecipients + - ", remoteDevicesCount=" + remoteDevicesCount + - ", participantLimit=" + participantLimit + - '}'; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt new file mode 100644 index 0000000000..20cd3b51f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt @@ -0,0 +1,128 @@ +package org.thoughtcrime.securesms.events + +import com.annimon.stream.OptionalLong +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink +import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager + +class WebRtcViewModel(state: WebRtcServiceState) { + + enum class State { + IDLE, + + // Normal states + CALL_PRE_JOIN, + CALL_INCOMING, + CALL_OUTGOING, + CALL_CONNECTED, + CALL_RINGING, + CALL_BUSY, + CALL_DISCONNECTED, + CALL_NEEDS_PERMISSION, + + // Error states + NETWORK_FAILURE, + RECIPIENT_UNAVAILABLE, + NO_SUCH_USER, + UNTRUSTED_IDENTITY, + + // Multiring Hangup States + CALL_ACCEPTED_ELSEWHERE, + CALL_DECLINED_ELSEWHERE, + CALL_ONGOING_ELSEWHERE; + + val isErrorState: Boolean + get() = this == NETWORK_FAILURE || this == RECIPIENT_UNAVAILABLE || this == NO_SUCH_USER || this == UNTRUSTED_IDENTITY + + val isPreJoinOrNetworkUnavailable: Boolean + get() = this == CALL_PRE_JOIN || this == NETWORK_FAILURE + + val isPassedPreJoin: Boolean + get() = ordinal > ordinal + } + + enum class GroupCallState { + IDLE, + RINGING, + DISCONNECTED, + CONNECTING, + RECONNECTING, + CONNECTED, + CONNECTED_AND_JOINING, + CONNECTED_AND_JOINED; + + val isIdle: Boolean + get() = this == IDLE + + val isNotIdle: Boolean + get() = this != IDLE + + val isConnected: Boolean + get() { + return when (this) { + CONNECTED, CONNECTED_AND_JOINING, CONNECTED_AND_JOINED -> true + else -> false + } + } + + val isNotIdleOrConnected: Boolean + get() { + return when (this) { + DISCONNECTED, CONNECTING, RECONNECTING -> true + else -> false + } + } + + val isRinging: Boolean + get() = this == RINGING + } + + val state: State = state.callInfoState.callState + val groupState: GroupCallState = state.callInfoState.groupCallState + val recipient: Recipient = state.callInfoState.callRecipient + val isRemoteVideoOffer: Boolean = state.callSetupState.isRemoteVideoOffer + val callConnectedTime: Long = state.callInfoState.callConnectedTime + val remoteParticipants: List = state.callInfoState.remoteCallParticipants + val identityChangedParticipants: Set = state.callInfoState.identityChangedRecipients + val remoteDevicesCount: OptionalLong = state.callInfoState.remoteDevicesCount + val participantLimit: Long? = state.callInfoState.participantLimit + @get:JvmName("shouldRingGroup") + val ringGroup: Boolean = state.callSetupState.ringGroup + val ringerRecipient: Recipient = state.callSetupState.ringerRecipient + val activeDevice: SignalAudioManager.AudioDevice = state.localDeviceState.activeDevice + val availableDevices: Set = state.localDeviceState.availableDevices + + val localParticipant: CallParticipant = createLocal( + state.localDeviceState.cameraState, + (if (state.videoState.localSink != null) state.videoState.localSink else BroadcastVideoSink())!!, + state.localDeviceState.isMicrophoneEnabled + ) + + val isRemoteVideoEnabled: Boolean + get() = remoteParticipants.any(CallParticipant::isVideoEnabled) || groupState.isNotIdle && remoteParticipants.size > 1 + + fun areRemoteDevicesInCall(): Boolean { + return remoteDevicesCount.isPresent && remoteDevicesCount.asLong > 0 + } + + override fun toString(): String { + return """ + WebRtcViewModel { + state=$state, + recipient=${recipient.id}, + isRemoteVideoOffer=$isRemoteVideoOffer, + callConnectedTime=$callConnectedTime, + localParticipant=$localParticipant, + remoteParticipants=$remoteParticipants, + identityChangedRecipients=$identityChangedParticipants, + remoteDevicesCount=$remoteDevicesCount, + participantLimit=$participantLimit, + activeDevice=$activeDevice, + availableDevices=$availableDevices, + } + """.trimIndent() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java index 9137652177..5333eafb35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.service.webrtc; -import android.media.AudioManager; - import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; @@ -13,7 +11,6 @@ import org.thoughtcrime.securesms.events.CallParticipantId; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; -import org.thoughtcrime.securesms.util.ServiceUtil; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_INCOMING_CONNECTING; @@ -76,9 +73,6 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor { Log.i(tag, "assign activePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode()); - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - androidAudioManager.setSpeakerphoneOn(false); - webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer); webRtcInteractor.retrieveTurnServers(remotePeer); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java index 3659c56cf4..fb8476a5e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java @@ -7,11 +7,11 @@ import org.signal.ringrtc.CallException; import org.signal.ringrtc.CallManager; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.WebRtcViewModel; -import org.thoughtcrime.securesms.ringrtc.CallState; import org.thoughtcrime.securesms.ringrtc.Camera; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.util.NetworkUtil; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_ESTABLISHED; @@ -39,7 +39,7 @@ public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor { RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); ApplicationDependencies.getAppForegroundObserver().removeListener(webRtcInteractor.getForegroundListener()); - webRtcInteractor.startAudioCommunication(activePeer.getState() == CallState.REMOTE_RINGING); + webRtcInteractor.startAudioCommunication(); activePeer.connected(); @@ -56,12 +56,10 @@ public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor { .callConnectedTime(System.currentTimeMillis()) .commit() .changeLocalDeviceState() - .wantsBluetooth(true) .build(); webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, activePeer); webRtcInteractor.unregisterPowerButtonReceiver(); - webRtcInteractor.setWantsBluetoothConnection(true); try { CallManager callManager = webRtcInteractor.getCallManager(); @@ -77,6 +75,12 @@ public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor { currentState = currentState.getActionProcessor().handleSetEnableVideo(currentState, true); } + if (currentState.getCallSetupState().isAcceptWithVideo() || currentState.getLocalDeviceState().getCameraState().isEnabled()) { + webRtcInteractor.setDefaultAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE, false); + } else { + webRtcInteractor.setDefaultAudioDevice(SignalAudioManager.AudioDevice.EARPIECE, false); + } + return currentState; } @@ -98,7 +102,7 @@ public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor { .cameraState(camera.getCameraState()) .build(); - WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getCallSetupState().isEnableVideoOnCreate()); + WebRtcUtil.enableSpeakerPhoneIfNeeded(webRtcInteractor, currentState); return currentState; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ConnectedCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ConnectedCallActionProcessor.java index 5e3797be68..195877cd88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ConnectedCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ConnectedCallActionProcessor.java @@ -55,7 +55,7 @@ public class ConnectedCallActionProcessor extends DeviceAwareActionProcessor { webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); } - WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getLocalDeviceState().getCameraState().isEnabled()); + WebRtcUtil.enableSpeakerPhoneIfNeeded(webRtcInteractor, currentState); return currentState; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java index ce527aefa9..e8dcba2c83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java @@ -1,15 +1,14 @@ package org.thoughtcrime.securesms.service.webrtc; -import android.media.AudioManager; - import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; -import org.thoughtcrime.securesms.ringrtc.Camera; import org.thoughtcrime.securesms.ringrtc.CameraState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; -import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; + +import java.util.Set; /** * Encapsulates the shared logic to deal with local device actions. Other action processors inherit @@ -23,76 +22,29 @@ public abstract class DeviceAwareActionProcessor extends WebRtcActionProcessor { } @Override - protected @NonNull WebRtcServiceState handleWiredHeadsetChange(@NonNull WebRtcServiceState currentState, boolean present) { - Log.i(tag, "handleWiredHeadsetChange():"); + protected @NonNull WebRtcServiceState handleAudioDeviceChanged(@NonNull WebRtcServiceState currentState, @NonNull SignalAudioManager.AudioDevice activeDevice, @NonNull Set availableDevices) { + Log.i(tag, "handleAudioDeviceChanged(): active: " + activeDevice + " available: " + availableDevices); - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - - if (present && androidAudioManager.isSpeakerphoneOn()) { - androidAudioManager.setSpeakerphoneOn(false); - androidAudioManager.setBluetoothScoOn(false); - } else if (!present && !androidAudioManager.isSpeakerphoneOn() && !androidAudioManager.isBluetoothScoOn() && currentState.getLocalDeviceState().getCameraState().isEnabled()) { - androidAudioManager.setSpeakerphoneOn(true); + if (!currentState.getLocalDeviceState().getCameraState().isEnabled()) { + webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); } - webRtcInteractor.postStateUpdate(currentState); + return currentState.builder() + .changeLocalDeviceState() + .setActiveDevice(activeDevice) + .setAvailableDevices(availableDevices) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleSetUserAudioDevice(@NonNull WebRtcServiceState currentState, @NonNull SignalAudioManager.AudioDevice userDevice) { + Log.i(tag, "handleSetUserAudioDevice(): userDevice: " + userDevice); + + webRtcInteractor.setUserAudioDevice(userDevice); return currentState; } - @Override - protected @NonNull WebRtcServiceState handleBluetoothChange(@NonNull WebRtcServiceState currentState, boolean available) { - Log.i(tag, "handleBluetoothChange(): " + available); - - if (available && currentState.getLocalDeviceState().wantsBluetooth()) { - webRtcInteractor.setWantsBluetoothConnection(true); - } - - return currentState.builder() - .changeLocalDeviceState() - .isBluetoothAvailable(available) - .build(); - } - - @Override - protected @NonNull WebRtcServiceState handleSetSpeakerAudio(@NonNull WebRtcServiceState currentState, boolean isSpeaker) { - Log.i(tag, "handleSetSpeakerAudio(): " + isSpeaker); - - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - - webRtcInteractor.setWantsBluetoothConnection(false); - androidAudioManager.setSpeakerphoneOn(isSpeaker); - - if (!currentState.getLocalDeviceState().getCameraState().isEnabled()) { - webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); - } - - webRtcInteractor.postStateUpdate(currentState); - - return currentState.builder() - .changeLocalDeviceState() - .wantsBluetooth(false) - .build(); - } - - @Override - protected @NonNull WebRtcServiceState handleSetBluetoothAudio(@NonNull WebRtcServiceState currentState, boolean isBluetooth) { - Log.i(tag, "handleSetBluetoothAudio(): " + isBluetooth); - - webRtcInteractor.setWantsBluetoothConnection(isBluetooth); - - if (!currentState.getLocalDeviceState().getCameraState().isEnabled()) { - webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); - } - - webRtcInteractor.postStateUpdate(currentState); - - return currentState.builder() - .changeLocalDeviceState() - .wantsBluetooth(isBluetooth) - .build(); - } - @Override protected @NonNull WebRtcServiceState handleSetCameraFlip(@NonNull WebRtcServiceState currentState) { Log.i(tag, "handleSetCameraFlip():"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java index d31db31ff4..0a806fd3df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java @@ -84,7 +84,7 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { .cameraState(camera.getCameraState()) .build(); - WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getCallSetupState().isEnableVideoOnCreate()); + WebRtcUtil.enableSpeakerPhoneIfNeeded(webRtcInteractor, currentState); return currentState; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupJoiningActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupJoiningActionProcessor.java index 8823eb197c..2acb5b9eb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupJoiningActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupJoiningActionProcessor.java @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.service.webrtc; -import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_ESTABLISHED; - import android.os.ResultReceiver; import androidx.annotation.NonNull; @@ -18,6 +16,8 @@ import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.NetworkUtil; import org.thoughtcrime.securesms.webrtc.locks.LockManager; +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_ESTABLISHED; + /** * Process actions to go from lobby to a joined call. */ @@ -62,7 +62,8 @@ public class GroupJoiningActionProcessor extends GroupActionProcessor { case CONNECTED: if (device.getJoinState() == GroupCall.JoinState.JOINED) { - webRtcInteractor.startAudioCommunication(true); + webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, currentState.getCallInfoState().getCallRecipient()); + webRtcInteractor.startAudioCommunication(); if (currentState.getLocalDeviceState().getCameraState().isEnabled()) { webRtcInteractor.updatePhoneState(LockManager.PhoneState.IN_VIDEO); @@ -70,9 +71,6 @@ public class GroupJoiningActionProcessor extends GroupActionProcessor { webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); } - webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, currentState.getCallInfoState().getCallRecipient()); - webRtcInteractor.setWantsBluetoothConnection(true); - try { groupCall.setOutgoingVideoMuted(!currentState.getLocalDeviceState().getCameraState().isEnabled()); groupCall.setOutgoingAudioMuted(!currentState.getLocalDeviceState().isMicrophoneEnabled()); @@ -96,7 +94,6 @@ public class GroupJoiningActionProcessor extends GroupActionProcessor { .callConnectedTime(System.currentTimeMillis()) .commit() .changeLocalDeviceState() - .wantsBluetooth(true) .commit() .actionProcessor(new GroupConnectedActionProcessor(webRtcInteractor)); } else if (device.getJoinState() == GroupCall.JoinState.JOINING) { @@ -152,7 +149,7 @@ public class GroupJoiningActionProcessor extends GroupActionProcessor { .cameraState(camera.getCameraState()) .build(); - WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getCallSetupState().isEnableVideoOnCreate()); + WebRtcUtil.enableSpeakerPhoneIfNeeded(webRtcInteractor, currentState); return currentState; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java index d1f8484775..4107dd5bb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.service.webrtc; -import android.media.AudioManager; - import androidx.annotation.NonNull; import com.annimon.stream.Stream; @@ -20,7 +18,6 @@ import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; import org.thoughtcrime.securesms.util.NetworkUtil; -import org.thoughtcrime.securesms.util.ServiceUtil; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import java.util.List; @@ -148,13 +145,9 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor { currentState = WebRtcVideoUtil.reinitializeCamera(context, webRtcInteractor.getCameraEventListener(), currentState); - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - androidAudioManager.setSpeakerphoneOn(false); - + webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, currentState.getCallInfoState().getCallRecipient()); webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); webRtcInteractor.initializeAudioForCall(); - webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, currentState.getCallInfoState().getCallRecipient()); - webRtcInteractor.setWantsBluetoothConnection(true); try { groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera()); @@ -174,7 +167,6 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor { .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING) .commit() .changeLocalDeviceState() - .wantsBluetooth(true) .build(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java index 874fab9f1f..9238da73c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.service.webrtc; -import android.media.AudioManager; import android.net.Uri; import androidx.annotation.NonNull; @@ -23,7 +22,6 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.util.NetworkUtil; -import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import org.whispersystems.libsignal.util.guava.Optional; @@ -108,11 +106,9 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState); - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - androidAudioManager.setSpeakerphoneOn(false); - webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, remotePeerGroup); webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE); + webRtcInteractor.initializeAudioForCall(); boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext()); if (shouldDisturbUserWithCall) { @@ -123,7 +119,6 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro } } - webRtcInteractor.initializeAudioForCall(); if (shouldDisturbUserWithCall && SignalStore.settings().isCallNotificationsEnabled()) { Uri ringtone = recipient.resolve().getCallRingtone(); RecipientDatabase.VibrateState vibrateState = recipient.resolve().getCallVibrate(); @@ -135,7 +130,6 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro webRtcInteractor.startIncomingRinger(ringtone, vibrateState == RecipientDatabase.VibrateState.ENABLED || (vibrateState == RecipientDatabase.VibrateState.DEFAULT && SignalStore.settings().isCallVibrateEnabled())); } - webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, remotePeerGroup); webRtcInteractor.registerPowerButtonReceiver(); return currentState.builder() @@ -196,13 +190,9 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro .enableVideoOnCreate(answerWithVideo) .build(); - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - androidAudioManager.setSpeakerphoneOn(false); - + webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, currentState.getCallInfoState().getCallRecipient()); webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); webRtcInteractor.initializeAudioForCall(); - webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, currentState.getCallInfoState().getCallRecipient()); - webRtcInteractor.setWantsBluetoothConnection(true); try { groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera()); @@ -222,7 +212,6 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING) .commit() .changeLocalDeviceState() - .wantsBluetooth(true) .build(); } @@ -246,7 +235,6 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro webRtcInteractor.updatePhoneState(LockManager.PhoneState.PROCESSING); webRtcInteractor.stopAudio(false); - webRtcInteractor.setWantsBluetoothConnection(false); webRtcInteractor.updatePhoneState(LockManager.PhoneState.IDLE); webRtcInteractor.stopForegroundService(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java index 87bcb12102..18533c1bec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.service.webrtc; -import android.media.AudioManager; import android.os.ResultReceiver; import androidx.annotation.NonNull; @@ -23,7 +22,7 @@ import org.thoughtcrime.securesms.service.webrtc.state.VideoState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; import org.thoughtcrime.securesms.util.NetworkUtil; -import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.webrtc.PeerConnection; import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; @@ -66,17 +65,14 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { Log.i(TAG, "assign activePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode()); - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - androidAudioManager.setSpeakerphoneOn(false); - WebRtcUtil.enableSpeakerPhoneIfNeeded(context, currentState.getCallSetupState().isEnableVideoOnCreate()); - + webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, remotePeer); + webRtcInteractor.setDefaultAudioDevice(currentState.getCallSetupState().isEnableVideoOnCreate() ? SignalAudioManager.AudioDevice.SPEAKER_PHONE + : SignalAudioManager.AudioDevice.EARPIECE, + false); webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context)); webRtcInteractor.initializeAudioForCall(); webRtcInteractor.startOutgoingRinger(); - webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, remotePeer); - webRtcInteractor.setWantsBluetoothConnection(true); - RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, Recipient.resolved(remotePeer.getId()), DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(remotePeer.getId())); DatabaseFactory.getSmsDatabase(context).insertOutgoingCall(remotePeer.getId(), currentState.getCallSetupState().isEnableVideoOnCreate()); @@ -87,7 +83,6 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { .callState(WebRtcViewModel.State.CALL_OUTGOING) .commit() .changeLocalDeviceState() - .wantsBluetooth(true) .build(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index 8e467c4f5b..43eaa76f2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -1,12 +1,5 @@ package org.thoughtcrime.securesms.service.webrtc; -import static org.thoughtcrime.securesms.events.WebRtcViewModel.GroupCallState.IDLE; -import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.CALL_INCOMING; -import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.NETWORK_FAILURE; -import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.NO_SUCH_USER; -import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.UNTRUSTED_IDENTITY; -import static org.thoughtcrime.securesms.service.webrtc.WebRtcUtil.getUrgencyFromCallUrgency; - import android.app.Application; import android.content.Context; import android.content.Intent; @@ -85,6 +78,13 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.stream.Collectors; +import static org.thoughtcrime.securesms.events.WebRtcViewModel.GroupCallState.IDLE; +import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.CALL_INCOMING; +import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.NETWORK_FAILURE; +import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.NO_SUCH_USER; +import static org.thoughtcrime.securesms.events.WebRtcViewModel.State.UNTRUSTED_IDENTITY; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcUtil.getUrgencyFromCallUrgency; + /** * Entry point for all things calling. Lives for the life of the app instance and will spin up a foreground service when needed to * handle "active" calls. @@ -126,7 +126,6 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. this.serviceState = new WebRtcServiceState(new IdleActionProcessor(new WebRtcInteractor(this.context, this, lockManager, - new SignalAudioManager(context), this, this, this))); @@ -193,14 +192,6 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleOrientationChanged(s, isLandscapeEnabled, degrees)); } - public void setAudioSpeaker(boolean isSpeaker) { - process((s, p) -> p.handleSetSpeakerAudio(s, isSpeaker)); - } - - public void setAudioBluetooth(boolean isBluetooth) { - process((s, p) -> p.handleSetBluetoothAudio(s, isBluetooth)); - } - public void setMuteAudio(boolean enabled) { process((s, p) -> p.handleSetMuteAudio(s, enabled)); } @@ -237,10 +228,6 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleIsInCallQuery(s, resultReceiver)); } - public void wiredHeadsetChange(boolean available) { - process((s, p) -> p.handleWiredHeadsetChange(s, available)); - } - public void networkChange(boolean available) { process((s, p) -> p.handleNetworkChanged(s, available)); } @@ -253,10 +240,6 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleScreenOffChange(s)); } - public void bluetoothChange(boolean available) { - process((s, p) -> p.handleBluetoothChange(s, available)); - } - public void postStateUpdate(@NonNull WebRtcServiceState state) { EventBus.getDefault().postSticky(new WebRtcViewModel(state)); } @@ -299,6 +282,14 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleReceivedGroupCallPeekForRingingCheck(s, groupCallRingCheckInfo, deviceCount)); } + public void onAudioDeviceChanged(@NonNull SignalAudioManager.AudioDevice activeDevice, @NonNull Set availableDevices) { + process((s, p) -> p.handleAudioDeviceChanged(s, activeDevice, availableDevices)); + } + + public void selectAudioDevice(@NonNull SignalAudioManager.AudioDevice desiredDevice) { + process((s, p) -> p.handleSetUserAudioDevice(s, desiredDevice)); + } + public void peekGroupCall(@NonNull RecipientId id) { if (callManager == null) { Log.i(TAG, "Unable to peekGroupCall, call manager is null"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java index 2df62b97ba..0d70ce0cab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java @@ -1,9 +1,5 @@ package org.thoughtcrime.securesms.service.webrtc; -import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.AnswerMetadata; -import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.HangupMetadata; -import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedAnswerMetadata; - import android.content.Context; import android.os.ResultReceiver; @@ -39,6 +35,7 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; import org.thoughtcrime.securesms.util.NetworkUtil; import org.thoughtcrime.securesms.util.TelephonyUtil; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import org.webrtc.PeerConnection; import org.whispersystems.libsignal.IdentityKey; @@ -52,8 +49,13 @@ import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMess import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.UUID; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.AnswerMetadata; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.HangupMetadata; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedAnswerMetadata; + /** * Base WebRTC action processor and core of the calling state machine. As actions (as intents) * are sent to the service, they are passed to an instance of the current state's action processor. @@ -370,6 +372,16 @@ public abstract class WebRtcActionProcessor { return builder.build(); } + protected @NonNull WebRtcServiceState handleAudioDeviceChanged(@NonNull WebRtcServiceState currentState, @NonNull SignalAudioManager.AudioDevice activeDevice, @NonNull Set availableDevices) { + Log.i(tag, "handleAudioDeviceChanged not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleSetUserAudioDevice(@NonNull WebRtcServiceState currentState, @NonNull SignalAudioManager.AudioDevice userDevice) { + Log.i(tag, "handleSetUserAudioDevice not processed"); + return currentState; + } + //endregion Active call //region Call setup @@ -410,16 +422,6 @@ public abstract class WebRtcActionProcessor { return currentState; } - protected @NonNull WebRtcServiceState handleSetSpeakerAudio(@NonNull WebRtcServiceState currentState, boolean isSpeaker) { - Log.i(tag, "handleSetSpeakerAudio not processed"); - return currentState; - } - - protected @NonNull WebRtcServiceState handleSetBluetoothAudio(@NonNull WebRtcServiceState currentState, boolean isBluetooth) { - Log.i(tag, "handleSetBluetoothAudio not processed"); - return currentState; - } - protected @NonNull WebRtcServiceState handleSetCameraFlip(@NonNull WebRtcServiceState currentState) { Log.i(tag, "handleSetCameraFlip not processed"); return currentState; @@ -430,16 +432,6 @@ public abstract class WebRtcActionProcessor { return currentState; } - protected @NonNull WebRtcServiceState handleBluetoothChange(@NonNull WebRtcServiceState currentState, boolean available) { - Log.i(tag, "handleBluetoothChange not processed"); - return currentState; - } - - protected @NonNull WebRtcServiceState handleWiredHeadsetChange(@NonNull WebRtcServiceState currentState, boolean present) { - Log.i(tag, "handleWiredHeadsetChange not processed"); - return currentState; - } - public @NonNull WebRtcServiceState handleCameraSwitchCompleted(@NonNull WebRtcServiceState currentState, @NonNull CameraState newCameraState) { Log.i(tag, "handleCameraSwitchCompleted not processed"); return currentState; @@ -564,7 +556,6 @@ public abstract class WebRtcActionProcessor { (activePeer.getState() == CallState.CONNECTED); webRtcInteractor.stopAudio(playDisconnectSound); - webRtcInteractor.setWantsBluetoothConnection(false); webRtcInteractor.updatePhoneState(LockManager.PhoneState.IDLE); webRtcInteractor.stopForegroundService(); @@ -574,7 +565,6 @@ public abstract class WebRtcActionProcessor { .activePeer(null) .commit() .changeLocalDeviceState() - .wantsBluetooth(false) .commit() .actionProcessor(currentState.getCallInfoState().getCallState() == WebRtcViewModel.State.CALL_DISCONNECTED ? new DisconnectingCallActionProcessor(webRtcInteractor) : new IdleActionProcessor(webRtcInteractor)) .terminate() @@ -723,7 +713,6 @@ public abstract class WebRtcActionProcessor { webRtcInteractor.updatePhoneState(LockManager.PhoneState.PROCESSING); boolean playDisconnectSound = currentState.getCallInfoState().getCallState() == WebRtcViewModel.State.CALL_DISCONNECTED; webRtcInteractor.stopAudio(playDisconnectSound); - webRtcInteractor.setWantsBluetoothConnection(false); webRtcInteractor.updatePhoneState(LockManager.PhoneState.IDLE); webRtcInteractor.stopForegroundService(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java index e83979bac6..a975d977ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java @@ -6,10 +6,8 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.media.AudioManager; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.os.Build; import android.os.IBinder; import android.telephony.PhoneStateListener; import android.telephony.TelephonyManager; @@ -25,16 +23,18 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.TelephonyUtil; import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager; -import org.thoughtcrime.securesms.webrtc.audio.BluetoothStateManager; +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCommand; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import java.util.Objects; +import java.util.Set; /** * Provide a foreground service for {@link SignalCallManager} to leverage to run in the background when necessary. Also * provides devices listeners needed for during a call (i.e., bluetooth, power button). */ -public final class WebRtcCallService extends Service implements BluetoothStateManager.BluetoothStateListener { +public final class WebRtcCallService extends Service implements SignalAudioManager.EventListener { private static final String TAG = Log.tag(WebRtcCallService.class); @@ -42,23 +42,23 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa private static final String ACTION_STOP = "STOP"; private static final String ACTION_DENY_CALL = "DENY_CALL"; private static final String ACTION_LOCAL_HANGUP = "LOCAL_HANGUP"; - private static final String ACTION_WANTS_BLUETOOTH = "WANTS_BLUETOOTH"; private static final String ACTION_CHANGE_POWER_BUTTON = "CHANGE_POWER_BUTTON"; + private static final String ACTION_SEND_AUDIO_COMMAND = "SEND_AUDIO_COMMAND"; - private static final String EXTRA_UPDATE_TYPE = "UPDATE_TYPE"; - private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; - private static final String EXTRA_ENABLED = "ENABLED"; + private static final String EXTRA_UPDATE_TYPE = "UPDATE_TYPE"; + private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; + private static final String EXTRA_ENABLED = "ENABLED"; + private static final String EXTRA_AUDIO_COMMAND = "AUDIO_COMMAND"; private static final int INVALID_NOTIFICATION_ID = -1; private SignalCallManager callManager; - private WiredHeadsetStateReceiver wiredHeadsetStateReceiver; private NetworkReceiver networkReceiver; private PowerButtonReceiver powerButtonReceiver; private UncaughtExceptionHandlerManager uncaughtExceptionHandlerManager; private PhoneStateListener hangUpRtcOnDeviceCallAnswered; - private BluetoothStateManager bluetoothStateManager; + private SignalAudioManager signalAudioManager; private int lastNotificationId; private Notification lastNotification; @@ -86,11 +86,10 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa return new Intent(context, WebRtcCallService.class).setAction(ACTION_LOCAL_HANGUP); } - public static void setWantsBluetoothConnection(@NonNull Context context, boolean enabled) { + public static void sendAudioManagerCommand(@NonNull Context context, @NonNull AudioManagerCommand command) { Intent intent = new Intent(context, WebRtcCallService.class); - intent.setAction(ACTION_WANTS_BLUETOOTH) - .putExtra(EXTRA_ENABLED, enabled); - + intent.setAction(ACTION_SEND_AUDIO_COMMAND) + .putExtra(EXTRA_AUDIO_COMMAND, command); ContextCompat.startForegroundService(context, intent); } @@ -107,12 +106,11 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa Log.v(TAG, "onCreate"); super.onCreate(); this.callManager = ApplicationDependencies.getSignalCallManager(); - this.bluetoothStateManager = new BluetoothStateManager(this, this); + this.signalAudioManager = new SignalAudioManager(this, this); this.hangUpRtcOnDeviceCallAnswered = new HangUpRtcOnPstnCallAnsweredListener(); this.lastNotificationId = INVALID_NOTIFICATION_ID; registerUncaughtExceptionHandler(); - registerWiredHeadsetStateReceiver(); registerNetworkReceiver(); TelephonyUtil.getManager(this) @@ -128,13 +126,8 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa uncaughtExceptionHandlerManager.unregister(); } - if (bluetoothStateManager != null) { - bluetoothStateManager.onDestroy(); - } - - if (wiredHeadsetStateReceiver != null) { - unregisterReceiver(wiredHeadsetStateReceiver); - wiredHeadsetStateReceiver = null; + if (signalAudioManager != null) { + signalAudioManager.shutdown(); } unregisterNetworkReceiver(); @@ -157,11 +150,9 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa setCallInProgressNotification(intent.getIntExtra(EXTRA_UPDATE_TYPE, 0), Objects.requireNonNull(intent.getParcelableExtra(EXTRA_RECIPIENT_ID))); return START_STICKY; - case ACTION_WANTS_BLUETOOTH: + case ACTION_SEND_AUDIO_COMMAND: setCallNotification(); - if (bluetoothStateManager != null) { - bluetoothStateManager.setWantsConnection(intent.getBooleanExtra(EXTRA_ENABLED, false)); - } + signalAudioManager.handleCommand(Objects.requireNonNull(intent.getParcelableExtra(EXTRA_AUDIO_COMMAND))); return START_STICKY; case ACTION_CHANGE_POWER_BUTTON: setCallNotification(); @@ -215,20 +206,6 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa uncaughtExceptionHandlerManager.registerHandler(new ProximityLockRelease(callManager.getLockManager())); } - private void registerWiredHeadsetStateReceiver() { - wiredHeadsetStateReceiver = new WiredHeadsetStateReceiver(); - - String action; - - if (Build.VERSION.SDK_INT >= 21) { - action = AudioManager.ACTION_HEADSET_PLUG; - } else { - action = Intent.ACTION_HEADSET_PLUG; - } - - registerReceiver(wiredHeadsetStateReceiver, new IntentFilter(action)); - } - private void registerNetworkReceiver() { if (networkReceiver == null) { networkReceiver = new NetworkReceiver(); @@ -267,8 +244,8 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa } @Override - public void onBluetoothStateChanged(boolean isAvailable) { - callManager.bluetoothChange(isAvailable); + public void onAudioDeviceChanged(@NonNull SignalAudioManager.AudioDevice activeDevice, @NonNull Set availableDevices) { + callManager.onAudioDeviceChanged(activeDevice, availableDevices); } private class HangUpRtcOnPstnCallAnsweredListener extends PhoneStateListener { @@ -286,15 +263,6 @@ public final class WebRtcCallService extends Service implements BluetoothStateMa } } - private static class WiredHeadsetStateReceiver extends BroadcastReceiver { - @Override - public void onReceive(@NonNull Context context, @NonNull Intent intent) { - int state = intent.getIntExtra("state", -1); - - ApplicationDependencies.getSignalCallManager().wiredHeadsetChange(state != 0); - } - } - private static class NetworkReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java index aa264a30e9..035aee604d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java @@ -14,7 +14,7 @@ import org.thoughtcrime.securesms.ringrtc.CameraEventListener; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.util.AppForegroundObserver; -import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger; +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCommand; import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; @@ -32,7 +32,6 @@ public class WebRtcInteractor { @NonNull private final Context context; @NonNull private final SignalCallManager signalCallManager; @NonNull private final LockManager lockManager; - @NonNull private final SignalAudioManager audioManager; @NonNull private final CameraEventListener cameraEventListener; @NonNull private final GroupCall.Observer groupCallObserver; @NonNull private final AppForegroundObserver.Listener foregroundListener; @@ -40,7 +39,6 @@ public class WebRtcInteractor { public WebRtcInteractor(@NonNull Context context, @NonNull SignalCallManager signalCallManager, @NonNull LockManager lockManager, - @NonNull SignalAudioManager audioManager, @NonNull CameraEventListener cameraEventListener, @NonNull GroupCall.Observer groupCallObserver, @NonNull AppForegroundObserver.Listener foregroundListener) @@ -48,7 +46,6 @@ public class WebRtcInteractor { this.context = context; this.signalCallManager = signalCallManager; this.lockManager = lockManager; - this.audioManager = audioManager; this.cameraEventListener = cameraEventListener; this.groupCallObserver = groupCallObserver; this.foregroundListener = foregroundListener; @@ -74,10 +71,6 @@ public class WebRtcInteractor { return foregroundListener; } - void setWantsBluetoothConnection(boolean enabled) { - WebRtcCallService.setWantsBluetoothConnection(context, enabled); - } - void updatePhoneState(@NonNull LockManager.PhoneState phoneState) { lockManager.updatePhoneState(phoneState); } @@ -131,27 +124,35 @@ public class WebRtcInteractor { } void silenceIncomingRinger() { - audioManager.silenceIncomingRinger(); + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.SilenceIncomingRinger()); } void initializeAudioForCall() { - audioManager.initializeAudioForCall(); + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.Initialize()); } void startIncomingRinger(@Nullable Uri ringtoneUri, boolean vibrate) { - audioManager.startIncomingRinger(ringtoneUri, vibrate); + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.StartIncomingRinger(ringtoneUri, vibrate)); } void startOutgoingRinger() { - audioManager.startOutgoingRinger(OutgoingRinger.Type.RINGING); + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.StartOutgoingRinger()); } void stopAudio(boolean playDisconnect) { - audioManager.stop(playDisconnect); + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.Stop(playDisconnect)); } - void startAudioCommunication(boolean preserveSpeakerphone) { - audioManager.startCommunication(preserveSpeakerphone); + void startAudioCommunication() { + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.Start()); + } + + public void setUserAudioDevice(@NonNull SignalAudioManager.AudioDevice userDevice) { + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.SetUserDevice(userDevice)); + } + + public void setDefaultAudioDevice(@NonNull SignalAudioManager.AudioDevice userDevice, boolean clearUserEarpieceSelection) { + WebRtcCallService.sendAudioManagerCommand(context, new AudioManagerCommand.SetDefaultDevice(userDevice, clearUserEarpieceSelection)); } void peekGroupCallForRingingCheck(@NonNull GroupCallRingCheckInfo groupCallRingCheckInfo) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java index 7f49010693..626fab10fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.service.webrtc; import android.content.Context; -import android.media.AudioManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -9,8 +8,11 @@ import androidx.annotation.Nullable; import org.signal.ringrtc.CallManager; import org.signal.ringrtc.GroupCall; import org.signal.ringrtc.PeekInfo; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.WebRtcViewModel; -import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.ecc.Curve; @@ -32,7 +34,7 @@ public final class WebRtcUtil { } public static @NonNull LockManager.PhoneState getInCallPhoneState(@NonNull Context context) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); + AudioManagerCompat audioManager = ApplicationDependencies.getAndroidCallAudioManager(); if (audioManager.isSpeakerphoneOn() || audioManager.isBluetoothScoOn() || audioManager.isWiredHeadsetOn()) { return LockManager.PhoneState.IN_HANDS_FREE_CALL; } else { @@ -72,17 +74,15 @@ public final class WebRtcUtil { return OpaqueMessage.Urgency.DROPPABLE; } - public static void enableSpeakerPhoneIfNeeded(@NonNull Context context, boolean enable) { - if (!enable) { + public static void enableSpeakerPhoneIfNeeded(@NonNull WebRtcInteractor webRtcInteractor, WebRtcServiceState currentState) { + if (!currentState.getLocalDeviceState().getCameraState().isEnabled()) { return; } - AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); - //noinspection deprecation - boolean shouldEnable = !(androidAudioManager.isSpeakerphoneOn() || androidAudioManager.isBluetoothScoOn() || androidAudioManager.isWiredHeadsetOn()); - - if (shouldEnable) { - androidAudioManager.setSpeakerphoneOn(true); + if (currentState.getLocalDeviceState().getActiveDevice() == SignalAudioManager.AudioDevice.EARPIECE || + currentState.getLocalDeviceState().getActiveDevice() == SignalAudioManager.AudioDevice.NONE) + { + webRtcInteractor.setDefaultAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE, true); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.java deleted file mode 100644 index 697554d73c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.thoughtcrime.securesms.service.webrtc.state; - -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.components.sensors.Orientation; -import org.thoughtcrime.securesms.ringrtc.CameraState; - -/** - * Local device specific state. - */ -public final class LocalDeviceState { - CameraState cameraState; - boolean microphoneEnabled; - boolean bluetoothAvailable; - boolean wantsBluetooth; - Orientation orientation; - boolean isLandscapeEnabled; - Orientation deviceOrientation; - - LocalDeviceState() { - this(CameraState.UNKNOWN, true, false, false, Orientation.PORTRAIT_BOTTOM_EDGE, false, Orientation.PORTRAIT_BOTTOM_EDGE); - } - - LocalDeviceState(@NonNull LocalDeviceState toCopy) { - this(toCopy.cameraState, toCopy.microphoneEnabled, toCopy.bluetoothAvailable, toCopy.wantsBluetooth, toCopy.orientation, toCopy.isLandscapeEnabled, toCopy.deviceOrientation); - } - - LocalDeviceState(@NonNull CameraState cameraState, - boolean microphoneEnabled, - boolean bluetoothAvailable, - boolean wantsBluetooth, - @NonNull Orientation orientation, - boolean isLandscapeEnabled, - @NonNull Orientation deviceOrientation) - { - this.cameraState = cameraState; - this.microphoneEnabled = microphoneEnabled; - this.bluetoothAvailable = bluetoothAvailable; - this.wantsBluetooth = wantsBluetooth; - this.orientation = orientation; - this.isLandscapeEnabled = isLandscapeEnabled; - this.deviceOrientation = deviceOrientation; - } - - public @NonNull CameraState getCameraState() { - return cameraState; - } - - public boolean isMicrophoneEnabled() { - return microphoneEnabled; - } - - public boolean isBluetoothAvailable() { - return bluetoothAvailable; - } - - public boolean wantsBluetooth() { - return wantsBluetooth; - } - - public @NonNull Orientation getOrientation() { - return orientation; - } - - public boolean isLandscapeEnabled() { - return isLandscapeEnabled; - } - - public @NonNull Orientation getDeviceOrientation() { - return deviceOrientation; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.kt new file mode 100644 index 0000000000..c558d65445 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.service.webrtc.state + +import org.thoughtcrime.securesms.components.sensors.Orientation +import org.thoughtcrime.securesms.ringrtc.CameraState +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager + +/** + * Local device specific state. + */ +data class LocalDeviceState constructor( + var cameraState: CameraState = CameraState.UNKNOWN, + var isMicrophoneEnabled: Boolean = true, + var orientation: Orientation = Orientation.PORTRAIT_BOTTOM_EDGE, + var isLandscapeEnabled: Boolean = false, + var deviceOrientation: Orientation = Orientation.PORTRAIT_BOTTOM_EDGE, + var activeDevice: SignalAudioManager.AudioDevice = SignalAudioManager.AudioDevice.NONE, + var availableDevices: Set = emptySet() +) { + + fun duplicate(): LocalDeviceState { + return copy() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceState.java index 5ab6c8c670..71950c69d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceState.java @@ -27,7 +27,7 @@ public final class WebRtcServiceState { this.actionProcessor = toCopy.actionProcessor; this.callSetupState = toCopy.callSetupState.duplicate(); this.callInfoState = new CallInfoState(toCopy.callInfoState); - this.localDeviceState = new LocalDeviceState(toCopy.localDeviceState); + this.localDeviceState = toCopy.localDeviceState.duplicate(); this.videoState = new VideoState(toCopy.videoState); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java index 67292f03c4..698da76840 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java @@ -18,8 +18,10 @@ import org.thoughtcrime.securesms.ringrtc.Camera; import org.thoughtcrime.securesms.ringrtc.CameraState; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.WebRtcActionProcessor; +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import java.util.Collection; +import java.util.Set; /** * Builder that creates a new {@link WebRtcServiceState} from an existing one and allows @@ -73,7 +75,7 @@ public class WebRtcServiceStateBuilder { private LocalDeviceState toBuild; public LocalDeviceStateBuilder() { - toBuild = new LocalDeviceState(WebRtcServiceStateBuilder.this.toBuild.localDeviceState); + toBuild = WebRtcServiceStateBuilder.this.toBuild.localDeviceState.duplicate(); } public @NonNull WebRtcServiceStateBuilder commit() { @@ -87,37 +89,37 @@ public class WebRtcServiceStateBuilder { } public @NonNull LocalDeviceStateBuilder cameraState(@NonNull CameraState cameraState) { - toBuild.cameraState = cameraState; + toBuild.setCameraState(cameraState); return this; } public @NonNull LocalDeviceStateBuilder isMicrophoneEnabled(boolean enabled) { - toBuild.microphoneEnabled = enabled; - return this; - } - - public @NonNull LocalDeviceStateBuilder isBluetoothAvailable(boolean available) { - toBuild.bluetoothAvailable = available; - return this; - } - - public @NonNull LocalDeviceStateBuilder wantsBluetooth(boolean wantsBluetooth) { - toBuild.wantsBluetooth = wantsBluetooth; + toBuild.setMicrophoneEnabled(enabled); return this; } public @NonNull LocalDeviceStateBuilder setOrientation(@NonNull Orientation orientation) { - toBuild.orientation = orientation; + toBuild.setOrientation(orientation); return this; } public @NonNull LocalDeviceStateBuilder setLandscapeEnabled(boolean isLandscapeEnabled) { - toBuild.isLandscapeEnabled = isLandscapeEnabled; + toBuild.setLandscapeEnabled(isLandscapeEnabled); return this; } public @NonNull LocalDeviceStateBuilder setDeviceOrientation(@NonNull Orientation deviceOrientation) { - toBuild.deviceOrientation = deviceOrientation; + toBuild.setDeviceOrientation(deviceOrientation); + return this; + } + + public @NonNull LocalDeviceStateBuilder setActiveDevice(@NonNull SignalAudioManager.AudioDevice audioDevice) { + toBuild.setActiveDevice(audioDevice); + return this; + } + + public @NonNull LocalDeviceStateBuilder setAvailableDevices(@NonNull Set availableDevices) { + toBuild.setAvailableDevices(availableDevices); return this; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContextExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContextExtensions.kt new file mode 100644 index 0000000000..410804f49d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContextExtensions.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.util + +import android.content.BroadcastReceiver +import android.content.Context + +fun Context.safeUnregisterReceiver(receiver: BroadcastReceiver?) { + if (receiver == null) { + return + } + + try { + unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt new file mode 100644 index 0000000000..1dd223f732 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import org.thoughtcrime.securesms.util.ParcelUtil + +/** + * Commands that can be issued to [SignalAudioManager] to perform various tasks. + * + * Additional context: The audio management is tied closely with the Android audio and thus benefits from being + * tied to the [org.thoughtcrime.securesms.service.webrtc.WebRtcCallService] lifecycle. Because of this, all + * calls have to go through an intent to the service and this allows one entry point for that but multiple + * operations. + */ +sealed class AudioManagerCommand : Parcelable { + + override fun writeToParcel(parcel: Parcel, flags: Int) = Unit + override fun describeContents(): Int = 0 + + class Initialize : AudioManagerCommand() { + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { Initialize() } + } + } + + class StartIncomingRinger(val ringtoneUri: Uri, val vibrate: Boolean) : AudioManagerCommand() { + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(ringtoneUri, flags) + ParcelUtil.writeBoolean(parcel, vibrate) + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { parcel -> + StartIncomingRinger( + ringtoneUri = parcel.readParcelable(Uri::class.java.classLoader)!!, + vibrate = ParcelUtil.readBoolean(parcel) + ) + } + } + } + + class StartOutgoingRinger : AudioManagerCommand() { + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { StartOutgoingRinger() } + } + } + + class SilenceIncomingRinger : AudioManagerCommand() { + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { SilenceIncomingRinger() } + } + } + + class Start : AudioManagerCommand() { + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { Start() } + } + } + + class Stop(val playDisconnect: Boolean) : AudioManagerCommand() { + override fun writeToParcel(parcel: Parcel, flags: Int) { + ParcelUtil.writeBoolean(parcel, playDisconnect) + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { Stop(ParcelUtil.readBoolean(it)) } + } + } + + class SetUserDevice(val device: SignalAudioManager.AudioDevice) : AudioManagerCommand() { + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeSerializable(device) + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { SetUserDevice(it.readSerializable() as SignalAudioManager.AudioDevice) } + } + } + + class SetDefaultDevice(val device: SignalAudioManager.AudioDevice, val clearUserEarpieceSelection: Boolean) : AudioManagerCommand() { + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeSerializable(device) + ParcelUtil.writeBoolean(parcel, clearUserEarpieceSelection) + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { parcel -> + SetDefaultDevice( + device = parcel.readSerializable() as SignalAudioManager.AudioDevice, + clearUserEarpieceSelection = ParcelUtil.readBoolean(parcel) + ) + } + } + } + + class ParcelCheat(private val createFrom: (Parcel) -> T) : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): T = createFrom(parcel) + override fun newArray(size: Int): Array = throw UnsupportedOperationException() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java index e4155aa574..e591185473 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java @@ -1,7 +1,10 @@ package org.thoughtcrime.securesms.webrtc.audio; +import android.annotation.SuppressLint; import android.content.Context; +import android.content.pm.PackageManager; import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; import android.media.AudioFocusRequest; import android.media.AudioManager; import android.media.SoundPool; @@ -30,6 +33,88 @@ public abstract class AudioManagerCompat { audioManager = ServiceUtil.getAudioManager(context); } + public boolean isBluetoothScoAvailableOffCall() { + return audioManager.isBluetoothScoAvailableOffCall(); + } + + public void startBluetoothSco() { + audioManager.startBluetoothSco(); + } + + public void stopBluetoothSco() { + audioManager.stopBluetoothSco(); + } + + public boolean isBluetoothScoOn() { + return audioManager.isBluetoothScoOn(); + } + + public void setBluetoothScoOn(boolean on) { + audioManager.setBluetoothScoOn(on); + } + + public int getMode() { + return audioManager.getMode(); + } + + public void setMode(int modeInCommunication) { + audioManager.setMode(modeInCommunication); + } + + public boolean isSpeakerphoneOn() { + return audioManager.isSpeakerphoneOn(); + } + + public void setSpeakerphoneOn(boolean on) { + audioManager.setSpeakerphoneOn(on); + } + + public boolean isMicrophoneMute() { + return audioManager.isMicrophoneMute(); + } + + public void setMicrophoneMute(boolean on) { + audioManager.setMicrophoneMute(on); + } + + public boolean hasEarpiece(@NonNull Context context) { + return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); + } + + @SuppressLint("WrongConstant") + public boolean isWiredHeadsetOn() { + if (Build.VERSION.SDK_INT < 23) { + //noinspection deprecation + return audioManager.isWiredHeadsetOn(); + } else { + AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL); + for (AudioDeviceInfo device : devices) { + final int type = device.getType(); + if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) { + return true; + } else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) { + return true; + } + } + return false; + } + } + + public float ringVolumeWithMinimum() { + int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING); + int maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_RING); + float volume = logVolume(currentVolume, maxVolume); + float minVolume = logVolume(15, 100); + return Math.max(volume, minVolume); + } + + private static float logVolume(int volume, int maxVolume) { + if (maxVolume == 0 || volume > maxVolume) { + return 0.5f; + } + return (float) (1 - (Math.log(maxVolume + 1 - volume) / Math.log(maxVolume + 1))); + } + abstract public SoundPool createSoundPool(); abstract public void requestCallAudioFocus(); abstract public void abandonCallAudioFocus(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/BluetoothStateManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/BluetoothStateManager.java deleted file mode 100644 index a5c8b0de63..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/BluetoothStateManager.java +++ /dev/null @@ -1,257 +0,0 @@ -package org.thoughtcrime.securesms.webrtc.audio; - -import android.annotation.SuppressLint; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothHeadset; -import android.bluetooth.BluetoothProfile; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.media.AudioManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.util.ServiceUtil; - -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Note: We will need to start handling new permissions once we move to target API 31 - */ -@SuppressLint("MissingPermission") -public class BluetoothStateManager { - - private static final String TAG = Log.tag(BluetoothStateManager.class); - private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2; - - private enum ScoConnection { - DISCONNECTED, - IN_PROGRESS, - CONNECTED - } - - private final Object LOCK = new Object(); - - private final Context context; - private final BluetoothAdapter bluetoothAdapter; - private BluetoothScoReceiver bluetoothScoReceiver; - private BluetoothConnectionReceiver bluetoothConnectionReceiver; - private final BluetoothStateListener listener; - private final AtomicBoolean destroyed; - - private volatile ScoConnection scoConnection = ScoConnection.DISCONNECTED; - private int scoConnectionAttempts = 0; - - private BluetoothHeadset bluetoothHeadset = null; - private boolean wantsConnection = false; - - public BluetoothStateManager(@NonNull Context context, @Nullable BluetoothStateListener listener) { - this.context = context.getApplicationContext(); - - BluetoothAdapter localAdapter = BluetoothAdapter.getDefaultAdapter(); - if (localAdapter == null) { - this.bluetoothAdapter = null; - this.listener = null; - this.destroyed = new AtomicBoolean(true); - return; - } - - this.bluetoothAdapter = localAdapter; - this.bluetoothScoReceiver = new BluetoothScoReceiver(); - this.bluetoothConnectionReceiver = new BluetoothConnectionReceiver(); - this.listener = listener; - this.destroyed = new AtomicBoolean(false); - - requestHeadsetProxyProfile(); - - this.context.registerReceiver(bluetoothConnectionReceiver, new IntentFilter(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)); - - Intent sticky = this.context.registerReceiver(bluetoothScoReceiver, new IntentFilter(getScoChangeIntent())); - - if (sticky != null) { - bluetoothScoReceiver.onReceive(context, sticky); - } - - handleBluetoothStateChange(); - } - - public void onDestroy() { - destroyed.set(true); - - if (bluetoothHeadset != null && bluetoothAdapter != null) { - this.bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset); - } - - if (bluetoothConnectionReceiver != null) { - context.unregisterReceiver(bluetoothConnectionReceiver); - bluetoothConnectionReceiver = null; - } - - if (bluetoothScoReceiver != null) { - context.unregisterReceiver(bluetoothScoReceiver); - bluetoothScoReceiver = null; - } - - this.bluetoothHeadset = null; - } - - public void setWantsConnection(boolean enabled) { - synchronized (LOCK) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - - this.wantsConnection = enabled; - - if (wantsConnection && isBluetoothAvailable() && scoConnection == ScoConnection.DISCONNECTED) { - if (scoConnectionAttempts > MAX_SCO_CONNECTION_ATTEMPTS) { - Log.w(TAG, "We've already attempted to start SCO too many times. Won't try again."); - } else { - scoConnectionAttempts++; - audioManager.startBluetoothSco(); - scoConnection = ScoConnection.IN_PROGRESS; - } - } else if (!wantsConnection && scoConnection == ScoConnection.CONNECTED) { - audioManager.stopBluetoothSco(); - audioManager.setBluetoothScoOn(false); - scoConnection = ScoConnection.DISCONNECTED; - } else if (!wantsConnection && scoConnection == ScoConnection.IN_PROGRESS) { - audioManager.stopBluetoothSco(); - scoConnection = ScoConnection.DISCONNECTED; - } - } - } - - private void handleBluetoothStateChange() { - if (!destroyed.get()) { - boolean isBluetoothAvailable = isBluetoothAvailable(); - - if (!isBluetoothAvailable) { - setWantsConnection(false); - } - - if (listener != null) { - listener.onBluetoothStateChanged(isBluetoothAvailable); - } - } - } - - private boolean isBluetoothAvailable() { - try { - synchronized (LOCK) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - - if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) return false; - if (!audioManager.isBluetoothScoAvailableOffCall()) return false; - - return bluetoothHeadset != null && !bluetoothHeadset.getConnectedDevices().isEmpty(); - } - } catch (Exception e) { - Log.w(TAG, e); - return false; - } - } - - private String getScoChangeIntent() { - return AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED; - } - - - private void requestHeadsetProxyProfile() { - this.bluetoothAdapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() { - @Override - public void onServiceConnected(int profile, BluetoothProfile proxy) { - if (destroyed.get()) { - Log.w(TAG, "Got bluetooth profile event after the service was destroyed. Ignoring."); - return; - } - - if (profile == BluetoothProfile.HEADSET) { - synchronized (LOCK) { - bluetoothHeadset = (BluetoothHeadset) proxy; - } - - Intent sticky = context.registerReceiver(null, new IntentFilter(getScoChangeIntent())); - bluetoothScoReceiver.onReceive(context, sticky); - - synchronized (LOCK) { - if (wantsConnection && isBluetoothAvailable() && scoConnection == ScoConnection.DISCONNECTED) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - audioManager.startBluetoothSco(); - scoConnection = ScoConnection.IN_PROGRESS; - } - } - - handleBluetoothStateChange(); - } - } - - @Override - public void onServiceDisconnected(int profile) { - Log.i(TAG, "onServiceDisconnected"); - if (profile == BluetoothProfile.HEADSET) { - bluetoothHeadset = null; - handleBluetoothStateChange(); - } - } - }, BluetoothProfile.HEADSET); - } - - private class BluetoothScoReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - if (intent == null) return; - Log.i(TAG, "onReceive"); - - synchronized (LOCK) { - if (getScoChangeIntent().equals(intent.getAction())) { - int status = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR); - - if (status == AudioManager.SCO_AUDIO_STATE_CONNECTED) { - if (bluetoothHeadset != null) { - List devices = bluetoothHeadset.getConnectedDevices(); - - for (BluetoothDevice device : devices) { - if (bluetoothHeadset.isAudioConnected(device)) { - scoConnection = ScoConnection.CONNECTED; - scoConnectionAttempts = 0; - - if (wantsConnection) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - audioManager.setBluetoothScoOn(true); - } - } - } - } - } else if (status == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { - setWantsConnection(false); - } - } - } - - handleBluetoothStateChange(); - } - } - - private class BluetoothConnectionReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "onReceive"); - if (intent.getAction().equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) { - int state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED); - if (state == BluetoothHeadset.STATE_CONNECTED) { - scoConnectionAttempts = 0; - } - } - handleBluetoothStateChange(); - } - } - - public interface BluetoothStateListener { - void onBluetoothStateChanged(boolean isAvailable); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.java index d1f83077ea..0634f8df5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.java @@ -51,6 +51,8 @@ public class IncomingRinger { if (shouldVibrate(context, player, ringerMode, vibrate)) { Log.i(TAG, "Starting vibration"); vibrator.vibrate(VIBRATE_PATTERN, 1); + } else { + Log.i(TAG, "Skipping vibration"); } if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt new file mode 100644 index 0000000000..8988d4fdce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.os.Handler +import android.os.Looper + +/** + * Handler to run all audio/bluetooth operations. Provides current thread + * assertion for enforcing use of the handler when necessary. + */ +class SignalAudioHandler(looper: Looper) : Handler(looper) { + + fun assertHandlerThread() { + if (!isOnHandler()) { + throw AssertionError("Must run on audio handler thread.") + } + } + + fun isOnHandler(): Boolean { + return Looper.myLooper() == looper + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.java deleted file mode 100644 index 9766c1398c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.java +++ /dev/null @@ -1,116 +0,0 @@ -package org.thoughtcrime.securesms.webrtc.audio; - - -import android.content.Context; -import android.media.AudioManager; -import android.media.SoundPool; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.ServiceUtil; - -public class SignalAudioManager { - - @SuppressWarnings("unused") - private static final String TAG = Log.tag(SignalAudioManager.class); - - private final Context context; - private final IncomingRinger incomingRinger; - private final OutgoingRinger outgoingRinger; - - private final SoundPool soundPool; - private final int connectedSoundId; - private final int disconnectedSoundId; - - private final AudioManagerCompat audioManagerCompat; - - public SignalAudioManager(@NonNull Context context) { - this.context = context.getApplicationContext(); - this.incomingRinger = new IncomingRinger(context); - this.outgoingRinger = new OutgoingRinger(context); - this.audioManagerCompat = AudioManagerCompat.create(context); - this.soundPool = audioManagerCompat.createSoundPool(); - - this.connectedSoundId = this.soundPool.load(context, R.raw.webrtc_completed, 1); - this.disconnectedSoundId = this.soundPool.load(context, R.raw.webrtc_disconnected, 1); - } - - public void initializeAudioForCall() { - audioManagerCompat.requestCallAudioFocus(); - } - - public void startIncomingRinger(@Nullable Uri ringtoneUri, boolean vibrate) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - boolean speaker = !audioManager.isWiredHeadsetOn() && !audioManager.isBluetoothScoOn(); - - audioManager.setMode(AudioManager.MODE_RINGTONE); - audioManager.setMicrophoneMute(false); - audioManager.setSpeakerphoneOn(speaker); - - incomingRinger.start(ringtoneUri, vibrate); - } - - public void startOutgoingRinger(OutgoingRinger.Type type) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - audioManager.setMicrophoneMute(false); - - audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - - outgoingRinger.start(type); - } - - public void silenceIncomingRinger() { - incomingRinger.stop(); - } - - public void startCommunication(boolean preserveSpeakerphone) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - - incomingRinger.stop(); - outgoingRinger.stop(); - - audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - - if (!preserveSpeakerphone) { - audioManager.setSpeakerphoneOn(false); - } - - float volume = ringVolumeWithMinimum(audioManager); - soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f); - } - - public void stop(boolean playDisconnected) { - AudioManager audioManager = ServiceUtil.getAudioManager(context); - - incomingRinger.stop(); - outgoingRinger.stop(); - - if (playDisconnected) { - float volume = ringVolumeWithMinimum(audioManager); - soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f); - } - - audioManager.setMode(AudioManager.MODE_NORMAL); - - audioManagerCompat.abandonCallAudioFocus(); - } - - private static float ringVolumeWithMinimum(@NonNull AudioManager audioManager) { - int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING); - int maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_RING); - float volume = logVolume(currentVolume, maxVolume); - float minVolume = logVolume(15, 100); - return Math.max(volume, minVolume); - } - - private static float logVolume(int volume, int maxVolume) { - if (maxVolume == 0 || volume > maxVolume) { - return 0.5f; - } - return (float) (1 - (Math.log(maxVolume + 1 - volume) / Math.log(maxVolume + 1))); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt new file mode 100644 index 0000000000..ac69db053a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -0,0 +1,373 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.media.SoundPool +import android.net.Uri +import android.os.Build +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.util.safeUnregisterReceiver +import org.whispersystems.libsignal.util.guava.Preconditions + +private val TAG = Log.tag(SignalAudioManager::class.java) + +/** + * Manage all audio and bluetooth routing for calling. Primarily, operates by maintaining a list + * of available devices (wired, speaker, bluetooth, earpiece) and then using a state machine to determine + * which device to use. Inputs into the decision include the [defaultAudioDevice] (set based on if audio + * only or video call) and [userSelectedAudioDevice] (set by user interaction with UI). [autoSwitchToWiredHeadset] + * and [autoSwitchToBluetooth] also impact the decision by forcing the user selection to the respective device + * when initially discovered. If the user switches to another device while bluetooth or wired headset are + * connected, the system will not auto switch back until the audio device is disconnected and reconnected. + * + * For example, call starts with speaker, then a bluetooth headset is connected. The audio will automatically + * switch to the headset. The user can then switch back to speaker through a manual interaction. If the + * bluetooth headset is then disconnected, and reconnected, the audio will again automatically switch to + * the bluetooth headset. + */ +class SignalAudioManager(private val context: Context, private val eventListener: EventListener?) { + + private var commandAndControlThread = SignalExecutors.getAndStartHandlerThread("call-audio") + private val handler = SignalAudioHandler(commandAndControlThread.looper) + + private val androidAudioManager = ApplicationDependencies.getAndroidCallAudioManager() + private val signalBluetoothManager = SignalBluetoothManager(context, this, handler) + + private var state: State = State.UNINITIALIZED + + private var savedAudioMode = AudioManager.MODE_INVALID + private var savedIsSpeakerPhoneOn = false + private var savedIsMicrophoneMute = false + private var hasWiredHeadset = false + private var autoSwitchToWiredHeadset = true + private var autoSwitchToBluetooth = true + + private var defaultAudioDevice: AudioDevice = AudioDevice.EARPIECE + private var selectedAudioDevice: AudioDevice = AudioDevice.NONE + private var userSelectedAudioDevice: AudioDevice = AudioDevice.NONE + + private var audioDevices: MutableSet = mutableSetOf() + + private val soundPool: SoundPool = androidAudioManager.createSoundPool() + private val connectedSoundId = soundPool.load(context, R.raw.webrtc_completed, 1) + private val disconnectedSoundId = soundPool.load(context, R.raw.webrtc_disconnected, 1) + + private val incomingRinger = IncomingRinger(context) + private val outgoingRinger = OutgoingRinger(context) + + private var wiredHeadsetReceiver: WiredHeadsetReceiver? = null + + fun handleCommand(command: AudioManagerCommand) { + handler.post { + when (command) { + is AudioManagerCommand.Initialize -> initialize() + is AudioManagerCommand.Start -> start() + is AudioManagerCommand.Stop -> stop(command.playDisconnect) + is AudioManagerCommand.SetDefaultDevice -> setDefaultAudioDevice(command.device, command.clearUserEarpieceSelection) + is AudioManagerCommand.SetUserDevice -> selectAudioDevice(command.device) + is AudioManagerCommand.StartIncomingRinger -> startIncomingRinger(command.ringtoneUri, command.vibrate) + is AudioManagerCommand.SilenceIncomingRinger -> silenceIncomingRinger() + is AudioManagerCommand.StartOutgoingRinger -> startOutgoingRinger() + } + } + } + + private fun initialize() { + Log.i(TAG, "Initializing audio manager state: $state") + + if (state == State.UNINITIALIZED) { + savedAudioMode = androidAudioManager.mode + savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn + savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute + hasWiredHeadset = androidAudioManager.isWiredHeadsetOn + + androidAudioManager.requestCallAudioFocus() + + setMicrophoneMute(false) + + audioDevices.clear() + + signalBluetoothManager.start() + + updateAudioDeviceState() + + wiredHeadsetReceiver = WiredHeadsetReceiver() + context.registerReceiver(wiredHeadsetReceiver, IntentFilter(if (Build.VERSION.SDK_INT >= 21) AudioManager.ACTION_HEADSET_PLUG else Intent.ACTION_HEADSET_PLUG)) + + state = State.PREINITIALIZED + + Log.d(TAG, "Initialized") + } + } + + private fun start() { + Log.d(TAG, "Starting. state: $state") + if (state == State.RUNNING) { + Log.w(TAG, "Skipping, already active") + return + } + + incomingRinger.stop() + outgoingRinger.stop() + + state = State.RUNNING + + androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + + val volume: Float = androidAudioManager.ringVolumeWithMinimum() + soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f) + + Log.d(TAG, "Started") + } + + private fun stop(playDisconnect: Boolean) { + Log.d(TAG, "Stopping. state: $state") + if (state == State.UNINITIALIZED) { + Log.i(TAG, "Trying to stop AudioManager in incorrect state: $state") + return + } + + incomingRinger.stop() + outgoingRinger.stop() + + if (playDisconnect) { + val volume: Float = androidAudioManager.ringVolumeWithMinimum() + soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f) + } + + state = State.UNINITIALIZED + + context.safeUnregisterReceiver(wiredHeadsetReceiver) + wiredHeadsetReceiver = null + + signalBluetoothManager.stop() + + setSpeakerphoneOn(savedIsSpeakerPhoneOn) + setMicrophoneMute(savedIsMicrophoneMute) + androidAudioManager.mode = savedAudioMode + + androidAudioManager.abandonCallAudioFocus() + Log.d(TAG, "Abandoned audio focus for VOICE_CALL streams") + + Log.d(TAG, "Stopped") + } + + fun shutdown() { + handler.post { + stop(false) + if (commandAndControlThread != null) { + Log.i(TAG, "Shutting down command and control") + commandAndControlThread.quitSafely() + commandAndControlThread = null + } + } + } + + fun updateAudioDeviceState() { + handler.assertHandlerThread() + + Log.i( + TAG, + "updateAudioDeviceState(): " + + "wired: $hasWiredHeadset " + + "bt: ${signalBluetoothManager.state} " + + "available: $audioDevices " + + "selected: $selectedAudioDevice " + + "userSelected: $userSelectedAudioDevice" + ) + + if (signalBluetoothManager.state.shouldUpdate()) { + signalBluetoothManager.updateDevice() + } + + val newAudioDevices = mutableSetOf(AudioDevice.SPEAKER_PHONE) + + if (signalBluetoothManager.state.hasDevice()) { + newAudioDevices += AudioDevice.BLUETOOTH + } + + if (hasWiredHeadset) { + newAudioDevices += AudioDevice.WIRED_HEADSET + } else { + autoSwitchToWiredHeadset = true + if (androidAudioManager.hasEarpiece(context)) { + newAudioDevices += AudioDevice.EARPIECE + } + } + + var audioDeviceSetUpdated = audioDevices != newAudioDevices + audioDevices = newAudioDevices + + if (signalBluetoothManager.state == SignalBluetoothManager.State.UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) { + userSelectedAudioDevice = AudioDevice.NONE + } + + if (hasWiredHeadset && autoSwitchToWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) { + userSelectedAudioDevice = AudioDevice.WIRED_HEADSET + autoSwitchToWiredHeadset = false + } + + if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) { + userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE + } + + val needBluetoothAudioStart = signalBluetoothManager.state == SignalBluetoothManager.State.AVAILABLE && + (userSelectedAudioDevice == AudioDevice.NONE || userSelectedAudioDevice == AudioDevice.BLUETOOTH || autoSwitchToBluetooth) + + val needBluetoothAudioStop = (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED || signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTING) && + (userSelectedAudioDevice != AudioDevice.NONE && userSelectedAudioDevice != AudioDevice.BLUETOOTH) + + if (signalBluetoothManager.state.hasDevice()) { + Log.i(TAG, "Need bluetooth audio: state: ${signalBluetoothManager.state} start: $needBluetoothAudioStart stop: $needBluetoothAudioStop") + } + + if (needBluetoothAudioStop) { + signalBluetoothManager.stopScoAudio() + signalBluetoothManager.updateDevice() + } + + if (!autoSwitchToBluetooth && signalBluetoothManager.state == SignalBluetoothManager.State.UNAVAILABLE) { + autoSwitchToBluetooth = true + } + + if (needBluetoothAudioStart && !needBluetoothAudioStop) { + if (!signalBluetoothManager.startScoAudio()) { + audioDevices.remove(AudioDevice.BLUETOOTH) + audioDeviceSetUpdated = true + } + } + + if (autoSwitchToBluetooth && signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) { + userSelectedAudioDevice = AudioDevice.BLUETOOTH + autoSwitchToBluetooth = false + } + + val newAudioDevice: AudioDevice = when { + audioDevices.contains(userSelectedAudioDevice) -> userSelectedAudioDevice + audioDevices.contains(defaultAudioDevice) -> defaultAudioDevice + else -> AudioDevice.SPEAKER_PHONE + } + + if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) { + setAudioDevice(newAudioDevice) + Log.i(TAG, "New device status: available: $audioDevices, selected: $newAudioDevice") + eventListener?.onAudioDeviceChanged(selectedAudioDevice, audioDevices) + } + } + + private fun setDefaultAudioDevice(newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) { + Log.d(TAG, "setDefaultAudioDevice(): currentDefault: $defaultAudioDevice device: $newDefaultDevice clearUser: $clearUserEarpieceSelection") + defaultAudioDevice = when (newDefaultDevice) { + AudioDevice.SPEAKER_PHONE -> newDefaultDevice + AudioDevice.EARPIECE -> { + if (androidAudioManager.hasEarpiece(context)) { + newDefaultDevice + } else { + AudioDevice.SPEAKER_PHONE + } + } + else -> throw AssertionError("Invalid default audio device selection") + } + + if (clearUserEarpieceSelection && userSelectedAudioDevice == AudioDevice.EARPIECE) { + Log.d(TAG, "Clearing user setting of earpiece") + userSelectedAudioDevice = AudioDevice.NONE + } + + Log.d(TAG, "New default: $defaultAudioDevice userSelected: $userSelectedAudioDevice") + updateAudioDeviceState() + } + + private fun selectAudioDevice(device: AudioDevice) { + val actualDevice = if (device == AudioDevice.EARPIECE && audioDevices.contains(AudioDevice.WIRED_HEADSET)) AudioDevice.WIRED_HEADSET else device + + Log.d(TAG, "selectAudioDevice(): device: $device actualDevice: $actualDevice") + if (!audioDevices.contains(actualDevice)) { + Log.w(TAG, "Can not select $actualDevice from available $audioDevices") + } + userSelectedAudioDevice = actualDevice + updateAudioDeviceState() + } + + private fun setAudioDevice(device: AudioDevice) { + Log.d(TAG, "setAudioDevice(): device: $device") + Preconditions.checkArgument(audioDevices.contains(device)) + when (device) { + AudioDevice.SPEAKER_PHONE -> setSpeakerphoneOn(true) + AudioDevice.EARPIECE -> setSpeakerphoneOn(false) + AudioDevice.WIRED_HEADSET -> setSpeakerphoneOn(false) + AudioDevice.BLUETOOTH -> setSpeakerphoneOn(false) + else -> throw AssertionError("Invalid audio device selection") + } + selectedAudioDevice = device + } + + private fun setSpeakerphoneOn(on: Boolean) { + if (androidAudioManager.isSpeakerphoneOn != on) { + androidAudioManager.isSpeakerphoneOn = on + } + } + + private fun setMicrophoneMute(on: Boolean) { + if (androidAudioManager.isMicrophoneMute != on) { + androidAudioManager.isMicrophoneMute = on + } + } + + private fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) { + Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate") + androidAudioManager.mode = AudioManager.MODE_RINGTONE + setMicrophoneMute(false) + setDefaultAudioDevice(AudioDevice.SPEAKER_PHONE, false) + + incomingRinger.start(ringtoneUri, vibrate) + } + + private fun silenceIncomingRinger() { + Log.i(TAG, "silenceIncomingRinger():") + incomingRinger.stop() + } + + private fun startOutgoingRinger() { + Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice") + + androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + setMicrophoneMute(false) + + outgoingRinger.start(OutgoingRinger.Type.RINGING) + } + + private fun onWiredHeadsetChange(pluggedIn: Boolean, hasMic: Boolean) { + Log.i(TAG, "onWiredHeadsetChange state: $state plug: $pluggedIn mic: $hasMic") + hasWiredHeadset = pluggedIn + updateAudioDeviceState() + } + + private inner class WiredHeadsetReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pluggedIn = intent.getIntExtra("state", 0) == 1 + val hasMic = intent.getIntExtra("microphone", 0) == 1 + + handler.post { onWiredHeadsetChange(pluggedIn, hasMic) } + } + } + + enum class AudioDevice { + SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE + } + + enum class State { + UNINITIALIZED, PREINITIALIZED, RUNNING + } + + interface EventListener { + @JvmSuppressWildcards + fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt new file mode 100644 index 0000000000..01f5a6c423 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt @@ -0,0 +1,355 @@ +package org.thoughtcrime.securesms.webrtc.audio + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothProfile +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.util.safeUnregisterReceiver +import java.util.concurrent.TimeUnit + +/** + * Manages the bluetooth lifecycle with a headset. This class doesn't make any + * determination on if bluetooth should be used. It determines if a device is connected, + * reports that to the [SignalAudioManager], and then handles connecting/disconnecting + * to the device if requested by [SignalAudioManager]. + */ +class SignalBluetoothManager( + private val context: Context, + private val audioManager: SignalAudioManager, + private val handler: SignalAudioHandler +) { + + var state: State = State.UNINITIALIZED + get() { + handler.assertHandlerThread() + return field + } + private set + + private var bluetoothAdapter: BluetoothAdapter? = null + private var bluetoothDevice: BluetoothDevice? = null + private var bluetoothHeadset: BluetoothHeadset? = null + private var scoConnectionAttempts = 0 + + private val androidAudioManager = ApplicationDependencies.getAndroidCallAudioManager() + private val bluetoothListener = BluetoothServiceListener() + private var bluetoothReceiver: BluetoothHeadsetBroadcastReceiver? = null + + private val bluetoothTimeout = { onBluetoothTimeout() } + + fun start() { + handler.assertHandlerThread() + + Log.d(TAG, "start(): $state") + + if (state != State.UNINITIALIZED) { + Log.w(TAG, "Invalid starting state") + return + } + + bluetoothHeadset = null + bluetoothDevice = null + scoConnectionAttempts = 0 + + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + if (bluetoothAdapter == null) { + Log.i(TAG, "Device does not support Bluetooth") + return + } + + if (!androidAudioManager.isBluetoothScoAvailableOffCall) { + Log.w(TAG, "Bluetooth SCO audio is not available off call") + return + } + + if (bluetoothAdapter?.getProfileProxy(context, bluetoothListener, BluetoothProfile.HEADSET) != true) { + Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed") + return + } + + val bluetoothHeadsetFilter = IntentFilter().apply { + addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) + addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) + } + + bluetoothReceiver = BluetoothHeadsetBroadcastReceiver() + context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter) + + Log.i(TAG, "Headset profile state: ${bluetoothAdapter?.getProfileConnectionState(BluetoothProfile.HEADSET)?.toStateString()}") + Log.i(TAG, "Bluetooth proxy for headset profile has started") + state = State.UNAVAILABLE + } + + fun stop() { + handler.assertHandlerThread() + + Log.d(TAG, "stop(): state: $state") + + if (bluetoothAdapter == null) { + return + } + + stopScoAudio() + + if (state == State.UNINITIALIZED) { + return + } + + context.safeUnregisterReceiver(bluetoothReceiver) + bluetoothReceiver = null + + cancelTimer() + + if (bluetoothHeadset != null) { + bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset) + bluetoothHeadset = null + } + + bluetoothAdapter = null + bluetoothDevice = null + state = State.UNINITIALIZED + } + + fun startScoAudio(): Boolean { + handler.assertHandlerThread() + + Log.i(TAG, "startScoAudio(): $state attempts: $scoConnectionAttempts") + + if (scoConnectionAttempts >= MAX_CONNECTION_ATTEMPTS) { + Log.w(TAG, "SCO connection attempts maxed out") + return false + } + + if (state != State.AVAILABLE) { + Log.w(TAG, "SCO connection failed as no headset available") + return false + } + + state = State.CONNECTING + androidAudioManager.startBluetoothSco() + androidAudioManager.isBluetoothScoOn = true + scoConnectionAttempts++ + startTimer() + + return true + } + + fun stopScoAudio() { + handler.assertHandlerThread() + + Log.i(TAG, "stopScoAudio(): $state") + + if (state != State.CONNECTING && state != State.CONNECTED) { + return + } + + cancelTimer() + androidAudioManager.stopBluetoothSco() + androidAudioManager.isBluetoothScoOn = false + state = State.DISCONNECTING + } + + fun updateDevice() { + handler.assertHandlerThread() + + Log.d(TAG, "updateDevice(): state: $state") + + if (state == State.UNINITIALIZED || bluetoothHeadset == null) { + return + } + + val devices: List? = bluetoothHeadset?.connectedDevices + if (devices == null || devices.isEmpty()) { + bluetoothDevice = null + state = State.UNAVAILABLE + Log.i(TAG, "No connected bluetooth headset") + } else { + bluetoothDevice = devices[0] + state = State.AVAILABLE + Log.i(TAG, "Connected bluetooth headset. headsetState: ${bluetoothHeadset?.getConnectionState(bluetoothDevice)?.toStateString()} scoAudio: ${bluetoothHeadset?.isAudioConnected(bluetoothDevice)}") + } + } + + private fun updateAudioDeviceState() { + audioManager.updateAudioDeviceState() + } + + private fun startTimer() { + handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT) + } + + private fun cancelTimer() { + handler.removeCallbacks(bluetoothTimeout) + } + + private fun onBluetoothTimeout() { + Log.i(TAG, "onBluetoothTimeout: state: $state bluetoothHeadset: $bluetoothHeadset") + + if (state == State.UNINITIALIZED || bluetoothHeadset == null || state != State.CONNECTING) { + return + } + + var scoConnected = false + val devices: List? = bluetoothHeadset?.connectedDevices + + if (devices != null && devices.isNotEmpty()) { + bluetoothDevice = devices[0] + if (bluetoothHeadset?.isAudioConnected(bluetoothDevice) == true) { + Log.d(TAG, "Connected with $bluetoothDevice") + scoConnected = true + } else { + Log.d(TAG, "Not connected with $bluetoothDevice") + } + } + + if (scoConnected) { + Log.i(TAG, "Device actually connected and not timed out") + state = State.CONNECTED + scoConnectionAttempts = 0 + } else { + Log.w(TAG, "Failed to connect after timeout") + stopScoAudio() + } + + updateAudioDeviceState() + } + + private fun onServiceConnected(proxy: BluetoothHeadset?) { + bluetoothHeadset = proxy + updateAudioDeviceState() + } + + private fun onServiceDisconnected() { + stopScoAudio() + bluetoothHeadset = null + bluetoothDevice = null + state = State.UNAVAILABLE + updateAudioDeviceState() + } + + private fun onHeadsetConnectionStateChanged(connectionState: Int) { + Log.i(TAG, "onHeadsetConnectionStateChanged: state: $state connectionState: ${connectionState.toStateString()}") + + when (connectionState) { + BluetoothHeadset.STATE_CONNECTED -> { + scoConnectionAttempts = 0 + updateAudioDeviceState() + } + BluetoothHeadset.STATE_DISCONNECTED -> { + stopScoAudio() + updateAudioDeviceState() + } + } + } + + private fun onAudioStateChanged(audioState: Int, isInitialStateChange: Boolean) { + Log.i(TAG, "onAudioStateChanged: state: $state audioState: ${audioState.toStateString()} initialSticky: $isInitialStateChange") + + if (audioState == BluetoothHeadset.STATE_AUDIO_CONNECTED) { + cancelTimer() + if (state === State.CONNECTING) { + Log.d(TAG, "Bluetooth audio SCO is now connected") + state = State.CONNECTED + scoConnectionAttempts = 0 + updateAudioDeviceState() + } else { + Log.w(TAG, "Unexpected state ${audioState.toStateString()}") + } + } else if (audioState == BluetoothHeadset.STATE_AUDIO_CONNECTING) { + Log.d(TAG, "Bluetooth audio SCO is now connecting...") + } else if (audioState == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) { + Log.d(TAG, "Bluetooth audio SCO is now disconnected") + if (isInitialStateChange) { + Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.") + return + } + updateAudioDeviceState() + } + } + + private inner class BluetoothServiceListener : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { + if (profile == BluetoothProfile.HEADSET) { + handler.post { + if (state != State.UNINITIALIZED) { + onServiceConnected(proxy as? BluetoothHeadset) + } + } + } + } + + override fun onServiceDisconnected(profile: Int) { + if (profile == BluetoothProfile.HEADSET) { + handler.post { + if (state != State.UNINITIALIZED) { + onServiceDisconnected() + } + } + } + } + } + + private inner class BluetoothHeadsetBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) { + val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED) + handler.post { + if (state != State.UNINITIALIZED) { + onHeadsetConnectionStateChanged(connectionState) + } + } + } else if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) { + val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED) + handler.post { + if (state != State.UNINITIALIZED) { + onAudioStateChanged(connectionState, isInitialStickyBroadcast) + } + } + } + } + } + + enum class State { + UNINITIALIZED, + UNAVAILABLE, + AVAILABLE, + DISCONNECTING, + CONNECTING, + CONNECTED, + ERROR; + + fun shouldUpdate(): Boolean { + return this == AVAILABLE || this == UNAVAILABLE || this == DISCONNECTING + } + + fun hasDevice(): Boolean { + return this == CONNECTED || this == CONNECTING || this == AVAILABLE + } + } + + companion object { + private val TAG = Log.tag(SignalBluetoothManager::class.java) + private val SCO_TIMEOUT = TimeUnit.SECONDS.toMillis(4) + private const val MAX_CONNECTION_ATTEMPTS = 2 + } +} + +private fun Int.toStateString(): String { + return when (this) { + BluetoothAdapter.STATE_DISCONNECTED -> "DISCONNECTED" + BluetoothAdapter.STATE_CONNECTED -> "CONNECTED" + BluetoothAdapter.STATE_CONNECTING -> "CONNECTING" + BluetoothAdapter.STATE_DISCONNECTING -> "DISCONNECTING" + BluetoothAdapter.STATE_OFF -> "OFF" + BluetoothAdapter.STATE_ON -> "ON" + BluetoothAdapter.STATE_TURNING_OFF -> "TURNING_OFF" + BluetoothAdapter.STATE_TURNING_ON -> "TURNING_ON" + else -> "UNKNOWN" + } +}