diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index a6e31cae08..554da0d4af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -86,6 +86,9 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.disposables.Disposable; + import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE; public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback { @@ -113,6 +116,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan private WindowLayoutInfoConsumer windowLayoutInfoConsumer; private ThrottledDebouncer requestNewSizesThrottle; + private Disposable ephemeralStateDisposable = Disposable.empty(); + @Override protected void attachBaseContext(@NonNull Context newBase) { getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); @@ -155,6 +160,18 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1)); } + @Override + protected void onStart() { + super.onStart(); + + ephemeralStateDisposable = ApplicationDependencies.getSignalCallManager() + .ephemeralStates() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(state -> { + viewModel.updateFromEphemeralState(state); + }); + } + @Override public void onResume() { Log.i(TAG, "onResume()"); @@ -195,6 +212,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan Log.i(TAG, "onStop"); super.onStop(); + ephemeralStateDisposable.dispose(); + if (!isInPipMode() || isFinishing()) { EventBus.getDefault().unregister(this); requestNewSizesThrottle.clear(); @@ -297,7 +316,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientationAndLandscapeEnabled(), - (s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second)) + viewModel.getEphemeralState(), + (s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second)) .observe(this, p -> callScreen.updateCallParticipants(p)); viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate); viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioIndicatorView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioIndicatorView.kt new file mode 100644 index 0000000000..bd5871377e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioIndicatorView.kt @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.components.webrtc + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.service.webrtc.WebRtcActionProcessor +import org.thoughtcrime.securesms.util.visible + +/** + * An indicator shown for each participant in a call which shows the state of their audio. + */ +class AudioIndicatorView(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) { + + companion object { + private const val SIDE_BAR_SHRINK_FACTOR = 0.75f + } + + private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.WHITE + } + + private val barRect = RectF() + private val barWidth = DimensionUnit.DP.toPixels(4f) + private val barRadius = DimensionUnit.DP.toPixels(32f) + private val barPadding = DimensionUnit.DP.toPixels(4f) + private var middleBarAnimation: ValueAnimator? = null + private var sideBarAnimation: ValueAnimator? = null + + private var showAudioLevel = false + private var lastAudioLevel: CallParticipant.AudioLevel? = null + + init { + inflate(context, R.layout.audio_indicator_view, this) + setWillNotDraw(false) + } + + private val micMuted: View = findViewById(R.id.mic_muted) + + fun bind(microphoneEnabled: Boolean, level: CallParticipant.AudioLevel?) { + micMuted.visible = !microphoneEnabled + + val wasShowingAudioLevel = showAudioLevel + showAudioLevel = microphoneEnabled && level != null + + if (showAudioLevel) { + val scaleFactor = when (level!!) { + CallParticipant.AudioLevel.LOWEST -> 0.2f + CallParticipant.AudioLevel.LOW -> 0.4f + CallParticipant.AudioLevel.MEDIUM -> 0.6f + CallParticipant.AudioLevel.HIGH -> 0.8f + CallParticipant.AudioLevel.HIGHEST -> 1.0f + } + + middleBarAnimation?.end() + + middleBarAnimation = createAnimation(middleBarAnimation, height * scaleFactor) + middleBarAnimation?.start() + + sideBarAnimation?.end() + + var finalHeight = height * scaleFactor + if (level != CallParticipant.AudioLevel.LOWEST) { + finalHeight *= SIDE_BAR_SHRINK_FACTOR + } + + sideBarAnimation = createAnimation(sideBarAnimation, finalHeight) + sideBarAnimation?.start() + } + + if (showAudioLevel != wasShowingAudioLevel || level != lastAudioLevel) { + invalidate() + } + + lastAudioLevel = level + } + + private fun createAnimation(current: ValueAnimator?, finalHeight: Float): ValueAnimator { + val currentHeight = current?.animatedValue as? Float ?: 0f + + return ValueAnimator.ofFloat(currentHeight, finalHeight).apply { + duration = WebRtcActionProcessor.AUDIO_LEVELS_INTERVAL.toLong() + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val middleBarHeight = middleBarAnimation?.animatedValue as? Float + val sideBarHeight = sideBarAnimation?.animatedValue as? Float + if (showAudioLevel && middleBarHeight != null && sideBarHeight != null) { + val audioLevelWidth = 3 * barWidth + 2 * barPadding + val xOffsetBase = (width - audioLevelWidth) / 2 + + canvas.drawBar( + xOffset = xOffsetBase, + size = sideBarHeight + ) + + canvas.drawBar( + xOffset = barPadding + barWidth + xOffsetBase, + size = middleBarHeight + ) + + canvas.drawBar( + xOffset = 2 * (barPadding + barWidth) + xOffsetBase, + size = sideBarHeight + ) + + if (middleBarAnimation?.isRunning == true || sideBarAnimation?.isRunning == true) { + invalidate() + } + } + } + + private fun Canvas.drawBar(xOffset: Float, size: Float) { + val yOffset = (height - size) / 2 + barRect.set(xOffset, yOffset, xOffset + barWidth, height - yOffset) + drawRoundRect(barRect, barRadius, barRadius, barPaint) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java index ca3ccb2c31..5e8d4132ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java @@ -62,7 +62,7 @@ public class CallParticipantView extends ConstraintLayout { private ImageView pipAvatar; private BadgeImageView pipBadge; private ContactPhoto contactPhoto; - private View audioMuted; + private AudioIndicatorView audioIndicator; private View infoOverlay; private EmojiTextView infoMessage; private Button infoMoreInfo; @@ -90,7 +90,7 @@ public class CallParticipantView extends ConstraintLayout { pipAvatar = findViewById(R.id.call_participant_item_pip_avatar); rendererFrame = findViewById(R.id.call_participant_renderer_frame); renderer = findViewById(R.id.call_participant_renderer); - audioMuted = findViewById(R.id.call_participant_mic_muted); + audioIndicator = findViewById(R.id.call_participant_audio_indicator); infoOverlay = findViewById(R.id.call_participant_info_overlay); infoIcon = findViewById(R.id.call_participant_info_icon); infoMessage = findViewById(R.id.call_participant_info_message); @@ -123,7 +123,7 @@ public class CallParticipantView extends ConstraintLayout { rendererFrame.setVisibility(View.GONE); renderer.setVisibility(View.GONE); renderer.attachBroadcastVideoSink(null); - audioMuted.setVisibility(View.GONE); + audioIndicator.setVisibility(View.GONE); avatar.setVisibility(View.GONE); badge.setVisibility(View.GONE); pipAvatar.setVisibility(View.GONE); @@ -159,7 +159,8 @@ public class CallParticipantView extends ConstraintLayout { renderer.attachBroadcastVideoSink(null); } - audioMuted.setVisibility(participant.isMicrophoneEnabled() ? View.GONE : View.VISIBLE); + audioIndicator.setVisibility(View.VISIBLE); + audioIndicator.bind(participant.isMicrophoneEnabled(), participant.getAudioLevel()); } if (participantChanged || !Objects.equals(contactPhoto, participant.getRecipient().getContactPhoto())) { 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 d003bb60f7..24ab6b4eed 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 @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.ringrtc.CameraState import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState import java.util.concurrent.TimeUnit /** @@ -260,6 +261,15 @@ data class CallParticipantsState( return oldState.copy(groupMembers = groupMembers) } + @JvmStatic + fun update(oldState: CallParticipantsState, ephemeralState: WebRtcEphemeralState): CallParticipantsState { + return oldState.copy( + remoteParticipants = oldState.remoteParticipants.map { p -> p.copy(audioLevel = ephemeralState.remoteAudioLevels[p.callParticipantId]) }, + localParticipant = oldState.localParticipant.copy(audioLevel = ephemeralState.localAudioLevel), + focusedParticipant = oldState.focusedParticipant.copy(audioLevel = ephemeralState.remoteAudioLevels[oldState.focusedParticipant.callParticipantId]) + ) + } + private fun determineLocalRenderMode( oldState: CallParticipantsState, localParticipant: CallParticipant = oldState.localParticipant, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java index 92ac6ddae5..6f209b3bc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -292,7 +292,7 @@ public class WebRtcCallView extends ConstraintLayout { rotatableControls.add(videoToggle); rotatableControls.add(cameraDirectionToggle); rotatableControls.add(decline); - rotatableControls.add(smallLocalRender.findViewById(R.id.call_participant_mic_muted)); + rotatableControls.add(smallLocalRender.findViewById(R.id.call_participant_audio_indicator)); rotatableControls.add(ringToggle); largeHeaderConstraints = new ConstraintSet(); 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 c7a21a10c3..4331b64e41 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 @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState; import org.thoughtcrime.securesms.util.DefaultValueLiveData; import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.Util; @@ -66,6 +67,7 @@ public class WebRtcCallViewModel extends ViewModel { private final MutableLiveData isLandscapeEnabled = new MutableLiveData<>(); private final LiveData controlsRotation; private final Observer> groupMemberStateUpdater = m -> participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), m)); + private final MutableLiveData ephemeralState = new MutableLiveData<>(); private final Handler elapsedTimeHandler = new Handler(Looper.getMainLooper()); private final Runnable elapsedTimeRunnable = this::handleTick; @@ -159,6 +161,10 @@ public class WebRtcCallViewModel extends ViewModel { return shouldShowSpeakerHint; } + public LiveData getEphemeralState() { + return ephemeralState; + } + public boolean canEnterPipMode() { return canEnterPipMode; } @@ -288,6 +294,11 @@ public class WebRtcCallViewModel extends ViewModel { } } + @MainThread + public void updateFromEphemeralState(@NonNull WebRtcEphemeralState state) { + ephemeralState.setValue(state); + } + private int resolveRotation(boolean isLandscapeEnabled, @NonNull Orientation orientation) { if (isLandscapeEnabled) { return 0; diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.kt b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.kt index 98f019630e..d48d86405f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.kt @@ -16,6 +16,7 @@ data class CallParticipant constructor( val isVideoEnabled: Boolean = false, val isMicrophoneEnabled: Boolean = false, val lastSpoke: Long = 0, + val audioLevel: AudioLevel? = null, val isMediaKeysReceived: Boolean = true, val addedToCallTime: Long = 0, val isScreenSharing: Boolean = false, @@ -73,6 +74,33 @@ data class CallParticipant constructor( PRIMARY, SECONDARY } + enum class AudioLevel { + LOWEST, + LOW, + MEDIUM, + HIGH, + HIGHEST; + + companion object { + + /** + * Converts a raw audio level from RingRTC (value in [0, 32767]) to a level suitable for + * display in the UI. + */ + @JvmStatic + fun fromRawAudioLevel(raw: Int?): AudioLevel? { + return when { + raw == null -> null + raw < 500 -> LOWEST + raw < 2000 -> LOW + raw < 8000 -> MEDIUM + raw < 20000 -> HIGH + else -> HIGHEST + } + } + } + } + companion object { @JvmField val EMPTY: CallParticipant = CallParticipant() 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 25a94eb36e..d740a8caac 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 @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.service.webrtc; import android.os.ResultReceiver; +import android.util.LongSparseArray; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -11,13 +12,17 @@ import org.signal.core.util.logging.Log; import org.signal.ringrtc.CallException; import org.signal.ringrtc.GroupCall; import org.signal.ringrtc.PeekInfo; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.ringrtc.Camera; import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.UUID; @@ -104,6 +109,31 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { .build(); } + @Override + protected @NonNull WebRtcEphemeralState handleGroupAudioLevelsChanged(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState) { + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + LongSparseArray remoteDeviceStates = groupCall.getRemoteDeviceStates(); + + CallParticipant.AudioLevel localAudioLevel = CallParticipant.AudioLevel.fromRawAudioLevel(groupCall.getLocalDeviceState().getAudioLevel()); + + HashMap remoteAudioLevels = new HashMap<>(); + for (CallParticipant participant : currentState.getCallInfoState().getRemoteCallParticipants()) { + CallParticipantId callParticipantId = participant.getCallParticipantId(); + + Integer audioLevel = null; + if (remoteDeviceStates != null) { + GroupCall.RemoteDeviceState state = remoteDeviceStates.get(callParticipantId.getDemuxId()); + if (state != null) { + audioLevel = state.getAudioLevel(); + } + } + + remoteAudioLevels.put(callParticipantId, CallParticipant.AudioLevel.fromRawAudioLevel(audioLevel)); + } + + return ephemeralState.copy(localAudioLevel, remoteAudioLevels); + } + @Override protected @NonNull WebRtcServiceState handleGroupJoinedMembershipChanged(@NonNull WebRtcServiceState currentState) { Log.i(tag, "handleGroupJoinedMembershipChanged():"); 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 f60cdbbec2..37b2ddba86 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 @@ -45,7 +45,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor { GroupCall groupCall = webRtcInteractor.getCallManager().createGroupCall(groupId, SignalStore.internalValues().groupCallingServer(), new byte[0], - null, + AUDIO_LEVELS_INTERVAL, AudioProcessingMethodSelector.get(), webRtcInteractor.getGroupCallObserver()); 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 cf73c4b940..65b3014ac4 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 @@ -170,7 +170,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro GroupCall groupCall = webRtcInteractor.getCallManager().createGroupCall(groupId, SignalStore.internalValues().groupCallingServer(), new byte[0], - null, + AUDIO_LEVELS_INTERVAL, AudioProcessingMethodSelector.get(), webRtcInteractor.getGroupCallObserver()); 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 d420eb7bb5..ff07522142 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 @@ -6,6 +6,7 @@ import android.content.Intent; import android.os.Build; import android.os.ResultReceiver; +import androidx.annotation.AnyThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -45,6 +46,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.ringrtc.CameraEventListener; import org.thoughtcrime.securesms.ringrtc.CameraState; import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.BubbleUtil; @@ -52,6 +54,7 @@ import org.thoughtcrime.securesms.util.NetworkUtil; import org.thoughtcrime.securesms.util.RecipientAccessList; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.rx.RxStore; import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import org.webrtc.PeerConnection; @@ -80,6 +83,10 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.stream.Collectors; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import kotlin.jvm.functions.Function1; + 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; @@ -106,16 +113,18 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. private final Executor networkExecutor; private final LockManager lockManager; - private WebRtcServiceState serviceState; - private boolean needsToSetSelfUuid = true; + private WebRtcServiceState serviceState; + private RxStore ephemeralStateStore; + private boolean needsToSetSelfUuid = true; public SignalCallManager(@NonNull Application application) { - this.context = application.getApplicationContext(); - this.messageSender = ApplicationDependencies.getSignalServiceMessageSender(); - this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); - this.lockManager = new LockManager(this.context); - this.serviceExecutor = Executors.newSingleThreadExecutor(); - this.networkExecutor = Executors.newSingleThreadExecutor(); + this.context = application.getApplicationContext(); + this.messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + this.lockManager = new LockManager(this.context); + this.serviceExecutor = Executors.newSingleThreadExecutor(); + this.networkExecutor = Executors.newSingleThreadExecutor(); + this.ephemeralStateStore = new RxStore<>(new WebRtcEphemeralState(), Schedulers.from(serviceExecutor)); CallManager callManager = null; try { @@ -133,6 +142,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. this))); } + public @NonNull Flowable ephemeralStates() { + return ephemeralStateStore.getStateFlowable(); + } + @NonNull CallManager getRingRtcCallManager() { //noinspection ConstantConditions return callManager; @@ -173,6 +186,16 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. }); } + /** + * Processes the given update to {@link WebRtcEphemeralState}. + * + * @param transformer The transformation to apply to the state. Runs on the {@link #serviceExecutor}. + */ + @AnyThread +private void processStateless(@NonNull Function1 transformer) { + ephemeralStateStore.update(transformer); + } + public void startPreJoinCall(@NonNull Recipient recipient) { process((s, p) -> p.handlePreJoinCall(s, new RemotePeer(recipient.getId()))); } @@ -766,7 +789,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. @Override public void onAudioLevels(@NonNull GroupCall groupCall) { - // TODO: Implement audio level handling for group calls. + processStateless(s -> serviceState.getActionProcessor().handleGroupAudioLevelsChanged(serviceState, s)); } @Override 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 96ae6df95f..12eb19f49a 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 @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.CallMetadata; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.OfferMetadata; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedOfferMetadata; +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; import org.thoughtcrime.securesms.util.NetworkUtil; @@ -77,6 +78,8 @@ import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedAnswe */ public abstract class WebRtcActionProcessor { + public static final int AUDIO_LEVELS_INTERVAL = 200; + protected final Context context; protected final WebRtcInteractor webRtcInteractor; protected final String tag; @@ -680,6 +683,10 @@ public abstract class WebRtcActionProcessor { return currentState; } + protected @NonNull WebRtcEphemeralState handleGroupAudioLevelsChanged(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState) { + return ephemeralState; + } + protected @NonNull WebRtcServiceState handleGroupJoinedMembershipChanged(@NonNull WebRtcServiceState currentState) { Log.i(tag, "handleGroupJoinedMembershipChanged not processed"); return currentState; diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java index 6e2a65a90e..578eb2c09c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/collections/ParticipantCollection.java @@ -13,6 +13,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; /** * Represents the participants to be displayed in the grid at any given time. @@ -90,6 +92,10 @@ public class ParticipantCollection { return participants; } + public @NonNull ParticipantCollection map(@NonNull Function mapper) { + return new ParticipantCollection(maxGridCellCount, participants.stream().map(mapper).collect(Collectors.toList())); + } + public int size() { return participants.size(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcEphemeralState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcEphemeralState.kt new file mode 100644 index 0000000000..9b4b03ae86 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcEphemeralState.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.service.webrtc.state + +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.events.CallParticipantId + +/** + * The state of the call system which contains data which changes frequently. + */ +data class WebRtcEphemeralState( + val localAudioLevel: CallParticipant.AudioLevel? = null, + val remoteAudioLevels: Map = emptyMap(), +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java index 85ea6dd425..1a3f19662d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -14,6 +14,7 @@ import com.annimon.stream.function.Predicate; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; +import org.whispersystems.signalservice.api.util.Preconditions; import java.util.LinkedHashSet; import java.util.List; @@ -98,6 +99,20 @@ public final class LiveDataUtil { return new CombineLiveData<>(a, b, combine); } + /** + * Once there is non-null data on each input {@link LiveData}, the {@link Combine3} function is + * run and produces a live data of the combined data. + *

+ * As each live data changes, the combine function is re-run, and a new value is emitted always + * with the latest, non-null values. + */ + public static LiveData combineLatest(@NonNull LiveData a, + @NonNull LiveData b, + @NonNull LiveData c, + @NonNull Combine3 combine) { + return new Combine3LiveData<>(a, b, c, combine); + } + /** * Merges the supplied live data streams. */ @@ -285,4 +300,41 @@ public final class LiveDataUtil { } } } + + private static final class Combine3LiveData extends MediatorLiveData { + private A a; + private B b; + private C c; + + Combine3LiveData(LiveData liveDataA, LiveData liveDataB, LiveData liveDataC, Combine3 combine) { + Preconditions.checkArgument(liveDataA != liveDataB && liveDataB != liveDataC && liveDataA != liveDataC); + + addSource(liveDataA, (a) -> { + if (a != null) { + this.a = a; + if (b != null && c != null) { + setValue(combine.apply(a, b, c)); + } + } + }); + + addSource(liveDataB, (b) -> { + if (b != null) { + this.b = b; + if (a != null && c != null) { + setValue(combine.apply(a, b, c)); + } + } + }); + + addSource(liveDataC, (c) -> { + if (c != null) { + this.c = c; + if (a != null && b != null) { + setValue(combine.apply(a, b, c)); + } + } + }); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt b/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt index dc6dea95a4..04bfd39eb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt @@ -1,15 +1,19 @@ package org.thoughtcrime.securesms.util.rx import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.PublishSubject /** * Rx replacement for Store. - * Actions are run on the computation thread. + * Actions are run on the computation thread by default. */ -class RxStore(defaultValue: T) { +class RxStore( + defaultValue: T, + private val scheduler: Scheduler = Schedulers.computation() +) { private val behaviorProcessor = BehaviorProcessor.createDefault(defaultValue) private val actionSubject = PublishSubject.create<(T) -> T>().toSerialized() @@ -19,7 +23,7 @@ class RxStore(defaultValue: T) { init { actionSubject - .observeOn(Schedulers.computation()) + .observeOn(scheduler) .scan(defaultValue) { v, f -> f(v) } .subscribe { behaviorProcessor.onNext(it) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt index be865115f6..7debae8c96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt @@ -1,9 +1,14 @@ package org.thoughtcrime.securesms.webrtc import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState -data class CallParticipantsViewState( - val callParticipantsState: CallParticipantsState, +class CallParticipantsViewState( + callParticipantsState: CallParticipantsState, + ephemeralState: WebRtcEphemeralState, val isPortrait: Boolean, val isLandscapeEnabled: Boolean -) +) { + + val callParticipantsState = CallParticipantsState.update(callParticipantsState, ephemeralState) +} diff --git a/app/src/main/res/layout/audio_indicator_view.xml b/app/src/main/res/layout/audio_indicator_view.xml new file mode 100644 index 0000000000..b747191e73 --- /dev/null +++ b/app/src/main/res/layout/audio_indicator_view.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/main/res/layout/call_participant_item.xml b/app/src/main/res/layout/call_participant_item.xml index 577dcaf63d..1fdd342772 100644 --- a/app/src/main/res/layout/call_participant_item.xml +++ b/app/src/main/res/layout/call_participant_item.xml @@ -83,16 +83,14 @@ - + app:layout_constraintStart_toStartOf="parent" />