From db7272730ea6510285acb82b318d32e78b11e12c Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 24 Aug 2021 10:18:39 -0400 Subject: [PATCH] Add Small Group Ringing support. --- app/build.gradle | 2 +- .../securesms/WebRtcCallActivity.java | 31 +- .../webrtc/CallParticipantsState.java | 378 ------------------ .../webrtc/CallParticipantsState.kt | 345 ++++++++++++++++ .../components/webrtc/WebRtcCallView.java | 34 +- .../webrtc/WebRtcCallViewModel.java | 115 ++++-- .../components/webrtc/WebRtcControls.java | 5 + .../securesms/database/DatabaseFactory.java | 6 + .../database/GroupCallRingDatabase.kt | 95 +++++ .../database/helpers/SQLCipherOpenHelper.java | 11 +- .../securesms/events/WebRtcViewModel.java | 25 ++ .../securesms/messages/GroupSendUtil.java | 69 ++++ .../notifications/DoNotDisturbUtil.java | 19 + .../service/webrtc/GroupActionProcessor.java | 84 +--- .../service/webrtc/GroupCallRingCheckInfo.kt | 14 + .../webrtc/GroupJoiningActionProcessor.java | 16 +- .../service/webrtc/IdleActionProcessor.java | 65 ++- .../IncomingGroupCallActionProcessor.java | 258 ++++++++++++ .../service/webrtc/SignalCallManager.java | 133 +++++- .../service/webrtc/WebRtcActionProcessor.java | 123 +++++- .../service/webrtc/WebRtcInteractor.java | 10 +- .../service/webrtc/state/CallSetupState.java | 44 -- .../service/webrtc/state/CallSetupState.kt | 25 ++ .../webrtc/state/WebRtcServiceState.java | 2 +- .../state/WebRtcServiceStateBuilder.java | 29 +- .../securesms/util/FeatureFlags.java | 20 +- .../webrtc/CallNotificationBuilder.java | 6 +- app/src/main/res/drawable/ic_ring_28.xml | 9 + .../main/res/drawable/ic_ring_disabled_28.xml | 9 + app/src/main/res/drawable/ic_ring_grey_28.xml | 9 + ...ebrtc_call_screen_circle_grey_disabled.xml | 5 + .../webrtc_call_screen_ring_toggle.xml | 21 + .../webrtc_call_screen_ring_toggle_small.xml | 21 + app/src/main/res/layout/webrtc_call_view.xml | 63 ++- app/src/main/res/values/strings.xml | 35 +- app/witness-verifications.gradle | 4 +- .../api/SignalServiceMessageSender.java | 21 + .../calls/SignalServiceCallMessage.java | 42 +- .../src/main/proto/SignalService.proto | 3 +- 39 files changed, 1597 insertions(+), 609 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/GroupCallRingDatabase.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupCallRingCheckInfo.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt create mode 100644 app/src/main/res/drawable/ic_ring_28.xml create mode 100644 app/src/main/res/drawable/ic_ring_disabled_28.xml create mode 100644 app/src/main/res/drawable/ic_ring_grey_28.xml create mode 100644 app/src/main/res/drawable/webrtc_call_screen_circle_grey_disabled.xml create mode 100644 app/src/main/res/drawable/webrtc_call_screen_ring_toggle.xml create mode 100644 app/src/main/res/drawable/webrtc_call_screen_ring_toggle_small.xml diff --git a/app/build.gradle b/app/build.gradle index 9f81547852..b9533f154d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -466,7 +466,7 @@ dependencies { implementation 'org.signal:argon2:13.1@aar' - implementation 'org.signal:ringrtc-android:2.10.8' + implementation 'org.signal:ringrtc-android:2.11.1' implementation "me.leolin:ShortcutBadger:1.1.22" implementation 'se.emilsjolander:stickylistheaders:2.7.0' diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 56ae7fa1c6..4ef0db8aa4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -17,6 +17,8 @@ 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; @@ -32,6 +34,7 @@ import android.os.Bundle; import android.util.Rational; import android.view.Window; import android.view.WindowManager; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -69,6 +72,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.EllapsedTimeFormatter; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FullscreenHelper; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ThrottledDebouncer; @@ -82,8 +86,6 @@ 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); @@ -290,13 +292,15 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls); viewModel.getEvents().observe(this, this::handleViewModelEvent); viewModel.getCallTime().observe(this, this::handleCallTime); + LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientationAndLandscapeEnabled(), (s, o) -> new CallParticipantsViewState(s, 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); - viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall()); + viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall()); + viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange); viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint); callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> { @@ -546,6 +550,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan ApplicationDependencies.getSignalCallManager().requestUpdateGroupMembers(); } + public void handleGroupMemberCountChange(int count) { + boolean canRing = count <= FeatureFlags.maxGroupCallRingSize() && FeatureFlags.groupCallRinging(); + callScreen.enableRingGroup(canRing); + ApplicationDependencies.getSignalCallManager().setRingGroup(canRing); + } + private void updateSpeakerHint(boolean showSpeakerHint) { if (showSpeakerHint) { callScreen.showSpeakerViewHint(); @@ -651,6 +661,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan private void handleCallPreJoin(@NonNull WebRtcViewModel event) { if (event.getGroupState().isNotIdle()) { callScreen.setStatusFromGroupCallState(event.getGroupState()); + callScreen.setRingGroup(event.shouldRingGroup()); + + if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) { + ApplicationDependencies.getSignalCallManager().setRingGroup(false); + } } } @@ -765,6 +780,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan public void onLocalPictureInPictureClicked() { viewModel.onLocalPictureInPictureClicked(); } + + @Override + public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) { + if (ringingAllowed) { + ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup); + } else { + ApplicationDependencies.getSignalCallManager().setRingGroup(false); + Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show(); + } + } } private class WindowLayoutInfoConsumer implements Consumer { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java deleted file mode 100644 index 63148c0568..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java +++ /dev/null @@ -1,378 +0,0 @@ -package org.thoughtcrime.securesms.components.webrtc; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import com.annimon.stream.ComparatorCompat; -import com.annimon.stream.OptionalLong; -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.events.CallParticipant; -import org.thoughtcrime.securesms.events.WebRtcViewModel; -import org.thoughtcrime.securesms.ringrtc.CameraState; -import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * Represents the state of all participants, remote and local, combined with view state - * needed to properly render the participants. The view state primarily consists of - * if we are in System PIP mode and if we should show our video for an outgoing call. - */ -public final class CallParticipantsState { - - private static final int SMALL_GROUP_MAX = 6; - - public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED, - WebRtcViewModel.GroupCallState.IDLE, - new ParticipantCollection(SMALL_GROUP_MAX), - CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(), false), - CallParticipant.EMPTY, - WebRtcLocalRenderState.GONE, - false, - false, - false, - OptionalLong.empty(), - WebRtcControls.FoldableState.flat()); - - private final WebRtcViewModel.State callState; - private final WebRtcViewModel.GroupCallState groupCallState; - private final ParticipantCollection remoteParticipants; - private final CallParticipant localParticipant; - private final CallParticipant focusedParticipant; - private final WebRtcLocalRenderState localRenderState; - private final boolean isInPipMode; - private final boolean showVideoForOutgoing; - private final boolean isViewingFocusedParticipant; - private final OptionalLong remoteDevicesCount; - private final WebRtcControls.FoldableState foldableState; - - public CallParticipantsState(@NonNull WebRtcViewModel.State callState, - @NonNull WebRtcViewModel.GroupCallState groupCallState, - @NonNull ParticipantCollection remoteParticipants, - @NonNull CallParticipant localParticipant, - @NonNull CallParticipant focusedParticipant, - @NonNull WebRtcLocalRenderState localRenderState, - boolean isInPipMode, - boolean showVideoForOutgoing, - boolean isViewingFocusedParticipant, - OptionalLong remoteDevicesCount, - @NonNull WebRtcControls.FoldableState foldableState) - { - this.callState = callState; - this.groupCallState = groupCallState; - this.remoteParticipants = remoteParticipants; - this.localParticipant = localParticipant; - this.localRenderState = localRenderState; - this.focusedParticipant = focusedParticipant; - this.isInPipMode = isInPipMode; - this.showVideoForOutgoing = showVideoForOutgoing; - this.isViewingFocusedParticipant = isViewingFocusedParticipant; - this.remoteDevicesCount = remoteDevicesCount; - this.foldableState = foldableState; - } - - public @NonNull WebRtcViewModel.State getCallState() { - return callState; - } - - public @NonNull WebRtcViewModel.GroupCallState getGroupCallState() { - return groupCallState; - } - - public @NonNull List getGridParticipants() { - return remoteParticipants.getGridParticipants(); - } - - public @NonNull List getListParticipants() { - List listParticipants = new ArrayList<>(); - - if (isViewingFocusedParticipant && getAllRemoteParticipants().size() > 1) { - listParticipants.addAll(getAllRemoteParticipants()); - listParticipants.remove(focusedParticipant); - } else { - listParticipants.addAll(remoteParticipants.getListParticipants()); - } - - if (foldableState.isFlat()) { - listParticipants.add(CallParticipant.EMPTY); - } - - Collections.reverse(listParticipants); - - return listParticipants; - } - - public @NonNull String getRemoteParticipantsDescription(@NonNull Context context) { - switch (remoteParticipants.size()) { - case 0: - return context.getString(R.string.WebRtcCallView__no_one_else_is_here); - case 1: { - if (callState == WebRtcViewModel.State.CALL_PRE_JOIN && groupCallState.isNotIdle()) { - return context.getString(remoteParticipants.get(0).isSelf() ? R.string.WebRtcCallView__s_are_in_this_call - : R.string.WebRtcCallView__s_is_in_this_call, - remoteParticipants.get(0).getShortRecipientDisplayName(context)); - } else { - if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) { - return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context)); - } else { - return remoteParticipants.get(0).getRecipientDisplayName(context); - } - } - } - case 2: { - if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) { - return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context)); - } else { - return context.getString(R.string.WebRtcCallView__s_and_s_are_in_this_call, - remoteParticipants.get(0).getShortRecipientDisplayName(context), - remoteParticipants.get(1).getShortRecipientDisplayName(context)); - } - } - default: { - if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) { - return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context)); - } else { - int others = remoteParticipants.size() - 2; - return context.getResources().getQuantityString(R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call, - others, - remoteParticipants.get(0).getShortRecipientDisplayName(context), - remoteParticipants.get(1).getShortRecipientDisplayName(context), - others); - } - } - } - } - - public @NonNull List getAllRemoteParticipants() { - return remoteParticipants.getAllParticipants(); - } - - public @NonNull CallParticipant getLocalParticipant() { - return localParticipant; - } - - public @NonNull CallParticipant getFocusedParticipant() { - return focusedParticipant; - } - - public @NonNull WebRtcLocalRenderState getLocalRenderState() { - return localRenderState; - } - - public boolean isFolded() { - return foldableState.isFolded(); - } - - public boolean isLargeVideoGroup() { - return getAllRemoteParticipants().size() > SMALL_GROUP_MAX; - } - - public boolean isInPipMode() { - return isInPipMode; - } - - public boolean isViewingFocusedParticipant() { - return isViewingFocusedParticipant; - } - - public boolean needsNewRequestSizes() { - if (groupCallState.isNotIdle()) { - return Stream.of(getAllRemoteParticipants()).anyMatch(p -> p.getVideoSink().needsNewRequestingSize()); - } else { - return false; - } - } - - public @NonNull OptionalLong getRemoteDevicesCount() { - return remoteDevicesCount; - } - - public @NonNull OptionalLong getParticipantCount() { - boolean includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED; - - return remoteDevicesCount.map(l -> l + (includeSelf ? 1L : 0L)) - .or(() -> includeSelf ? OptionalLong.of(1L) : OptionalLong.empty()); - } - - public boolean isIncomingRing() { - return callState == WebRtcViewModel.State.CALL_INCOMING; - } - - public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, - @NonNull WebRtcViewModel webRtcViewModel, - boolean enableVideo) - { - boolean newShowVideoForOutgoing = oldState.showVideoForOutgoing; - if (enableVideo) { - newShowVideoForOutgoing = webRtcViewModel.getState() == WebRtcViewModel.State.CALL_OUTGOING; - } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_OUTGOING) { - newShowVideoForOutgoing = false; - } - - WebRtcLocalRenderState localRenderState = determineLocalRenderMode(webRtcViewModel.getLocalParticipant(), - oldState.isInPipMode, - newShowVideoForOutgoing, - webRtcViewModel.getGroupState().isNotIdle(), - webRtcViewModel.getState(), - webRtcViewModel.getRemoteParticipants().size(), - oldState.isViewingFocusedParticipant, - oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); - - return new CallParticipantsState(webRtcViewModel.getState(), - webRtcViewModel.getGroupState(), - oldState.remoteParticipants.getNext(webRtcViewModel.getRemoteParticipants()), - webRtcViewModel.getLocalParticipant(), - getFocusedParticipant(webRtcViewModel.getRemoteParticipants()), - localRenderState, - oldState.isInPipMode, - newShowVideoForOutgoing, - oldState.isViewingFocusedParticipant, - webRtcViewModel.getRemoteDevicesCount(), - oldState.foldableState); - } - - public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, boolean isInPip) { - WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, - isInPip, - oldState.showVideoForOutgoing, - oldState.getGroupCallState().isNotIdle(), - oldState.callState, - oldState.getAllRemoteParticipants().size(), - oldState.isViewingFocusedParticipant, - oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); - - return new CallParticipantsState(oldState.callState, - oldState.groupCallState, - oldState.remoteParticipants, - oldState.localParticipant, - oldState.focusedParticipant, - localRenderState, - isInPip, - oldState.showVideoForOutgoing, - oldState.isViewingFocusedParticipant, - oldState.remoteDevicesCount, - oldState.foldableState); - } - - public static @NonNull CallParticipantsState setExpanded(@NonNull CallParticipantsState oldState, boolean expanded) { - WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, - oldState.isInPipMode, - oldState.showVideoForOutgoing, - oldState.getGroupCallState().isNotIdle(), - oldState.callState, - oldState.getAllRemoteParticipants().size(), - oldState.isViewingFocusedParticipant, - expanded); - - return new CallParticipantsState(oldState.callState, - oldState.groupCallState, - oldState.remoteParticipants, - oldState.localParticipant, - oldState.focusedParticipant, - localRenderState, - oldState.isInPipMode, - oldState.showVideoForOutgoing, - oldState.isViewingFocusedParticipant, - oldState.remoteDevicesCount, - oldState.foldableState); - } - - public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) { - WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, - oldState.isInPipMode, - oldState.showVideoForOutgoing, - oldState.getGroupCallState().isNotIdle(), - oldState.callState, - oldState.getAllRemoteParticipants().size(), - selectedPage == SelectedPage.FOCUSED, - oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); - - return new CallParticipantsState(oldState.callState, - oldState.groupCallState, - oldState.remoteParticipants, - oldState.localParticipant, - oldState.focusedParticipant, - localRenderState, - oldState.isInPipMode, - oldState.showVideoForOutgoing, - selectedPage == SelectedPage.FOCUSED, - oldState.remoteDevicesCount, - oldState.foldableState); - } - - public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull WebRtcControls.FoldableState foldableState) { - WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, - oldState.isInPipMode, - oldState.showVideoForOutgoing, - oldState.getGroupCallState().isNotIdle(), - oldState.callState, - oldState.getAllRemoteParticipants().size(), - oldState.isViewingFocusedParticipant, - oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); - - return new CallParticipantsState(oldState.callState, - oldState.groupCallState, - oldState.remoteParticipants, - oldState.localParticipant, - oldState.focusedParticipant, - localRenderState, - oldState.isInPipMode, - oldState.showVideoForOutgoing, - oldState.isViewingFocusedParticipant, - oldState.remoteDevicesCount, - foldableState); - } - - private static @NonNull WebRtcLocalRenderState determineLocalRenderMode(@NonNull CallParticipant localParticipant, - boolean isInPip, - boolean showVideoForOutgoing, - boolean isNonIdleGroupCall, - @NonNull WebRtcViewModel.State callState, - int numberOfRemoteParticipants, - boolean isViewingFocusedParticipant, - boolean isExpanded) - { - boolean displayLocal = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled()); - WebRtcLocalRenderState localRenderState = WebRtcLocalRenderState.GONE; - - if (isExpanded && (localParticipant.isVideoEnabled() || isNonIdleGroupCall)) { - return WebRtcLocalRenderState.EXPANDED; - } else if (displayLocal || showVideoForOutgoing) { - if (callState == WebRtcViewModel.State.CALL_CONNECTED) { - if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) { - localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE; - } else if (numberOfRemoteParticipants == 1) { - localRenderState = WebRtcLocalRenderState.SMALL_RECTANGLE; - } else { - localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO; - } - } else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) { - localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO; - } - } else if (callState == WebRtcViewModel.State.CALL_PRE_JOIN) { - localRenderState = WebRtcLocalRenderState.LARGE_NO_VIDEO; - } - - return localRenderState; - } - - private static @NonNull CallParticipant getFocusedParticipant(@NonNull List participants) { - List participantsByLastSpoke = new ArrayList<>(participants); - Collections.sort(participantsByLastSpoke, ComparatorCompat.reversed((p1, p2) -> Long.compare(p1.getLastSpoke(), p2.getLastSpoke()))); - - return participantsByLastSpoke.isEmpty() ? CallParticipant.EMPTY - : participantsByLastSpoke.stream() - .filter(CallParticipant::isScreenSharing) - .findAny().orElse(participantsByLastSpoke.get(0)); - } - - public enum SelectedPage { - GRID, - FOCUSED - } -} 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 new file mode 100644 index 0000000000..a4b1018c74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt @@ -0,0 +1,345 @@ +package org.thoughtcrime.securesms.components.webrtc + +import android.content.Context +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import com.annimon.stream.OptionalLong +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal +import org.thoughtcrime.securesms.events.WebRtcViewModel +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 java.util.concurrent.TimeUnit + +/** + * Represents the state of all participants, remote and local, combined with view state + * needed to properly render the participants. The view state primarily consists of + * if we are in System PIP mode and if we should show our video for an outgoing call. + */ +data class CallParticipantsState( + val callState: WebRtcViewModel.State = WebRtcViewModel.State.CALL_DISCONNECTED, + val groupCallState: WebRtcViewModel.GroupCallState = WebRtcViewModel.GroupCallState.IDLE, + private val remoteParticipants: ParticipantCollection = ParticipantCollection(SMALL_GROUP_MAX), + val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false), + val focusedParticipant: CallParticipant = CallParticipant.EMPTY, + val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE, + val isInPipMode: Boolean = false, + private val showVideoForOutgoing: Boolean = false, + val isViewingFocusedParticipant: Boolean = false, + val remoteDevicesCount: OptionalLong = OptionalLong.empty(), + private val foldableState: FoldableState = FoldableState.flat(), + val isInOutgoingRingingMode: Boolean = false, + val ringGroup: Boolean = false, + val ringerRecipient: Recipient = Recipient.UNKNOWN, + val groupMembers: List = emptyList() +) { + + val allRemoteParticipants: List = remoteParticipants.allParticipants + val isFolded: Boolean = foldableState.isFolded + val isLargeVideoGroup: Boolean = allRemoteParticipants.size > SMALL_GROUP_MAX + val isIncomingRing: Boolean = callState == WebRtcViewModel.State.CALL_INCOMING + + val gridParticipants: List + get() { + return remoteParticipants.gridParticipants + } + + val listParticipants: List + get() { + val listParticipants: MutableList = mutableListOf() + if (isViewingFocusedParticipant && allRemoteParticipants.size > 1) { + listParticipants.addAll(allRemoteParticipants) + listParticipants.remove(focusedParticipant) + } else { + listParticipants.addAll(remoteParticipants.listParticipants) + } + if (foldableState.isFlat) { + listParticipants.add(CallParticipant.EMPTY) + } + listParticipants.reverse() + return listParticipants + } + + val participantCount: OptionalLong + get() { + val includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED + return remoteDevicesCount.map { l: Long -> l + if (includeSelf) 1L else 0L } + .or { if (includeSelf) OptionalLong.of(1L) else OptionalLong.empty() } + } + + fun getPreJoinGroupDescription(context: Context): String? { + if (callState != WebRtcViewModel.State.CALL_PRE_JOIN || groupCallState.isIdle) { + return null + } + + return if (remoteParticipants.isEmpty) { + describeGroupMembers( + context = context, + oneParticipant = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s else R.string.WebRtcCallView__s_will_be_notified, + twoParticipants = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s_and_s else R.string.WebRtcCallView__s_and_s_will_be_notified, + multipleParticipants = if (ringGroup) R.plurals.WebRtcCallView__signal_will_ring_s_s_and_d_others else R.plurals.WebRtcCallView__s_s_and_d_others_will_be_notified, + members = groupMembers + ) + } else { + when (remoteParticipants.size()) { + 0 -> context.getString(R.string.WebRtcCallView__no_one_else_is_here) + 1 -> context.getString(if (remoteParticipants[0].isSelf) R.string.WebRtcCallView__s_are_in_this_call else R.string.WebRtcCallView__s_is_in_this_call, remoteParticipants[0].getShortRecipientDisplayName(context)) + 2 -> context.getString( + R.string.WebRtcCallView__s_and_s_are_in_this_call, + remoteParticipants[0].getShortRecipientDisplayName(context), + remoteParticipants[1].getShortRecipientDisplayName(context) + ) + else -> { + val others = remoteParticipants.size() - 2 + context.resources.getQuantityString( + R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call, + others, + remoteParticipants[0].getShortRecipientDisplayName(context), + remoteParticipants[1].getShortRecipientDisplayName(context), + others + ) + } + } + } + } + + fun getOutgoingRingingGroupDescription(context: Context): String? { + if (callState == WebRtcViewModel.State.CALL_CONNECTED && + groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED && + isInOutgoingRingingMode + ) { + return describeGroupMembers( + context = context, + oneParticipant = R.string.WebRtcCallView__ringing_s, + twoParticipants = R.string.WebRtcCallView__ringing_s_and_s, + multipleParticipants = R.plurals.WebRtcCallView__ringing_s_s_and_d_others, + members = groupMembers + ) + } + + return null + } + + fun getIncomingRingingGroupDescription(context: Context): String? { + if (callState == WebRtcViewModel.State.CALL_INCOMING && + groupCallState == WebRtcViewModel.GroupCallState.RINGING && + ringerRecipient.hasUuid() + ) { + val ringerName = ringerRecipient.getShortDisplayName(context) + val membersWithoutYouOrRinger: List = groupMembers.filterNot { it.member.isSelf || ringerRecipient.requireUuid() == it.member.uuid.orNull() } + + return when (membersWithoutYouOrRinger.size) { + 0 -> context.getString(R.string.WebRtcCallView__s_is_calling_you, ringerName) + 1 -> context.getString( + R.string.WebRtcCallView__s_is_calling_you_and_s, + ringerName, + membersWithoutYouOrRinger[0].member.getShortDisplayName(context) + ) + 2 -> context.getString( + R.string.WebRtcCallView__s_is_calling_you_s_and_s, + ringerName, + membersWithoutYouOrRinger[0].member.getShortDisplayName(context), + membersWithoutYouOrRinger[1].member.getShortDisplayName(context) + ) + else -> { + val others = membersWithoutYouOrRinger.size - 2 + context.resources.getQuantityString( + R.plurals.WebRtcCallView__s_is_calling_you_s_s_and_d_others, + others, + ringerName, + membersWithoutYouOrRinger[0].member.getShortDisplayName(context), + membersWithoutYouOrRinger[1].member.getShortDisplayName(context), + others + ) + } + } + } + + return null + } + + fun needsNewRequestSizes(): Boolean { + return if (groupCallState.isNotIdle) { + allRemoteParticipants.any { it.videoSink.needsNewRequestingSize() } + } else { + false + } + } + + companion object { + private const val SMALL_GROUP_MAX = 6 + + @JvmField + val MAX_OUTGOING_GROUP_RING_DURATION = TimeUnit.MINUTES.toMillis(1) + + @JvmField + val STARTING_STATE = CallParticipantsState() + + @JvmStatic + fun update( + oldState: CallParticipantsState, + webRtcViewModel: WebRtcViewModel, + enableVideo: Boolean + ): CallParticipantsState { + + var newShowVideoForOutgoing: Boolean = oldState.showVideoForOutgoing + if (enableVideo) { + newShowVideoForOutgoing = webRtcViewModel.state == WebRtcViewModel.State.CALL_OUTGOING + } else if (webRtcViewModel.state != WebRtcViewModel.State.CALL_OUTGOING) { + newShowVideoForOutgoing = false + } + + val isInOutgoingRingingMode = if (oldState.isInOutgoingRingingMode) { + webRtcViewModel.callConnectedTime + MAX_OUTGOING_GROUP_RING_DURATION > System.currentTimeMillis() && webRtcViewModel.remoteParticipants.size == 0 + } else { + oldState.ringGroup && + webRtcViewModel.callConnectedTime + MAX_OUTGOING_GROUP_RING_DURATION > System.currentTimeMillis() && + webRtcViewModel.remoteParticipants.size == 0 && + oldState.callState == WebRtcViewModel.State.CALL_OUTGOING && + webRtcViewModel.state == WebRtcViewModel.State.CALL_CONNECTED + } + + val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode( + oldState = oldState, + localParticipant = webRtcViewModel.localParticipant, + showVideoForOutgoing = newShowVideoForOutgoing, + isNonIdleGroupCall = webRtcViewModel.groupState.isNotIdle, + callState = webRtcViewModel.state, + numberOfRemoteParticipants = webRtcViewModel.remoteParticipants.size + ) + + return oldState.copy( + callState = webRtcViewModel.state, + groupCallState = webRtcViewModel.groupState, + remoteParticipants = oldState.remoteParticipants.getNext(webRtcViewModel.remoteParticipants), + localParticipant = webRtcViewModel.localParticipant, + focusedParticipant = getFocusedParticipant(webRtcViewModel.remoteParticipants), + localRenderState = localRenderState, + showVideoForOutgoing = newShowVideoForOutgoing, + remoteDevicesCount = webRtcViewModel.remoteDevicesCount, + ringGroup = webRtcViewModel.shouldRingGroup(), + isInOutgoingRingingMode = isInOutgoingRingingMode, + ringerRecipient = webRtcViewModel.ringerRecipient + ) + } + + @JvmStatic + fun update(oldState: CallParticipantsState, isInPip: Boolean): CallParticipantsState { + val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isInPip = isInPip) + + return oldState.copy(localRenderState = localRenderState, isInPipMode = isInPip) + } + + @JvmStatic + fun setExpanded(oldState: CallParticipantsState, expanded: Boolean): CallParticipantsState { + val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isExpanded = expanded) + + return oldState.copy(localRenderState = localRenderState) + } + + @JvmStatic + fun update(oldState: CallParticipantsState, selectedPage: SelectedPage): CallParticipantsState { + val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isViewingFocusedParticipant = selectedPage == SelectedPage.FOCUSED) + + return oldState.copy(localRenderState = localRenderState, isViewingFocusedParticipant = selectedPage == SelectedPage.FOCUSED) + } + + @JvmStatic + fun update(oldState: CallParticipantsState, foldableState: FoldableState): CallParticipantsState { + val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState) + + return oldState.copy(localRenderState = localRenderState, foldableState = foldableState) + } + + @JvmStatic + fun update(oldState: CallParticipantsState, groupMembers: List): CallParticipantsState { + return oldState.copy(groupMembers = groupMembers) + } + + private fun determineLocalRenderMode( + oldState: CallParticipantsState, + localParticipant: CallParticipant = oldState.localParticipant, + isInPip: Boolean = oldState.isInPipMode, + showVideoForOutgoing: Boolean = oldState.showVideoForOutgoing, + isNonIdleGroupCall: Boolean = oldState.groupCallState.isNotIdle, + callState: WebRtcViewModel.State = oldState.callState, + numberOfRemoteParticipants: Int = oldState.allRemoteParticipants.size, + isViewingFocusedParticipant: Boolean = oldState.isViewingFocusedParticipant, + isExpanded: Boolean = oldState.localRenderState == WebRtcLocalRenderState.EXPANDED + ): WebRtcLocalRenderState { + + val displayLocal: Boolean = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled) + var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE + + if (isExpanded && (localParticipant.isVideoEnabled || isNonIdleGroupCall)) { + return WebRtcLocalRenderState.EXPANDED + } else if (displayLocal || showVideoForOutgoing) { + if (callState == WebRtcViewModel.State.CALL_CONNECTED) { + localRenderState = if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) { + WebRtcLocalRenderState.SMALLER_RECTANGLE + } else if (numberOfRemoteParticipants == 1) { + WebRtcLocalRenderState.SMALL_RECTANGLE + } else { + if (localParticipant.isVideoEnabled) WebRtcLocalRenderState.LARGE else WebRtcLocalRenderState.LARGE_NO_VIDEO + } + } else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) { + localRenderState = if (localParticipant.isVideoEnabled) WebRtcLocalRenderState.LARGE else WebRtcLocalRenderState.LARGE_NO_VIDEO + } + } else if (callState == WebRtcViewModel.State.CALL_PRE_JOIN) { + localRenderState = WebRtcLocalRenderState.LARGE_NO_VIDEO + } + return localRenderState + } + + private fun getFocusedParticipant(participants: List): CallParticipant { + val participantsByLastSpoke: List = participants.sortedByDescending(CallParticipant::lastSpoke) + + return if (participantsByLastSpoke.isEmpty()) { + CallParticipant.EMPTY + } else { + participantsByLastSpoke.firstOrNull(CallParticipant::isScreenSharing) ?: participantsByLastSpoke[0] + } + } + + private fun describeGroupMembers( + context: Context, + @StringRes oneParticipant: Int, + @StringRes twoParticipants: Int, + @PluralsRes multipleParticipants: Int, + members: List + ): String { + val membersWithoutYou: List = members.filterNot { it.member.isSelf } + + return when (membersWithoutYou.size) { + 0 -> "" + 1 -> context.getString( + oneParticipant, + membersWithoutYou[0].member.getShortDisplayName(context) + ) + 2 -> context.getString( + twoParticipants, + membersWithoutYou[0].member.getShortDisplayName(context), + membersWithoutYou[1].member.getShortDisplayName(context) + ) + else -> { + val others = membersWithoutYou.size - 2 + context.resources.getQuantityString( + multipleParticipants, + others, + membersWithoutYou[0].member.getShortDisplayName(context), + membersWithoutYou[1].member.getShortDisplayName(context), + others + ) + } + } + } + } + + enum class SelectedPage { + GRID, FOCUSED + } +} 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 74f3ec0352..ee9f356c22 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 @@ -91,6 +91,8 @@ public class WebRtcCallView extends ConstraintLayout { private ImageView answer; private ImageView cameraDirectionToggle; private TextView cameraDirectionToggleLabel; + private AccessibleToggleButton ringToggle; + private TextView ringToggleLabel; private PictureInPictureGestureHelper pictureInPictureGestureHelper; private ImageView hangup; private TextView hangupLabel; @@ -171,6 +173,8 @@ public class WebRtcCallView extends ConstraintLayout { answer = findViewById(R.id.call_screen_answer_call); cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle); cameraDirectionToggleLabel = findViewById(R.id.call_screen_camera_direction_toggle_label); + ringToggle = findViewById(R.id.call_screen_audio_ring_toggle); + ringToggleLabel = findViewById(R.id.call_screen_audio_ring_toggle_label); hangup = findViewById(R.id.call_screen_end_call); hangupLabel = findViewById(R.id.call_screen_end_call_label); answerWithAudio = findViewById(R.id.call_screen_answer_with_audio); @@ -239,6 +243,10 @@ public class WebRtcCallView extends ConstraintLayout { runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn)); }); + ringToggle.setOnCheckedChangeListener((v, isOn) -> { + runIfNonNull(controlsListener, listener -> listener.onRingGroupChanged(isOn, ringToggle.isActivated())); + }); + cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged)); hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed)); @@ -358,8 +366,14 @@ public class WebRtcCallView extends ConstraintLayout { pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode(), isPortrait, isLandscapeEnabled)); } - if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN && state.getGroupCallState().isNotIdle()) { - status.setText(state.getRemoteParticipantsDescription(getContext())); + if (state.getGroupCallState().isNotIdle()) { + if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) { + status.setText(state.getPreJoinGroupDescription(getContext())); + } else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) { + status.setText(state.getOutgoingRingingGroupDescription(getContext())); + } else if (state.getGroupCallState().isRinging()) { + status.setText(state.getIncomingRingingGroupDescription(getContext())); + } } if (state.getGroupCallState().isNotIdle()) { @@ -641,6 +655,11 @@ public class WebRtcCallView extends ConstraintLayout { fullScreenShade.setVisibility(GONE); } + if (webRtcControls.displayRingToggle()) { + visibleViewSet.add(ringToggle); + visibleViewSet.add(ringToggleLabel); + } + if (webRtcControls.isFadeOutEnabled()) { if (!controls.isFadeOutEnabled()) { scheduleFadeOut(); @@ -947,6 +966,7 @@ public class WebRtcCallView extends ConstraintLayout { micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle); videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle); audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle); + ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle); } private void updateButtonStateForSmallButtons() { @@ -955,6 +975,7 @@ public class WebRtcCallView extends ConstraintLayout { micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle_small); videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle_small); audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle_small); + ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle_small); } private boolean showParticipantsList() { @@ -968,6 +989,14 @@ public class WebRtcCallView extends ConstraintLayout { } } + public void setRingGroup(boolean shouldRingGroup) { + ringToggle.setChecked(shouldRingGroup, false); + } + + public void enableRingGroup(boolean enabled) { + ringToggle.setActivated(enabled); + } + public interface ControlsListener { void onStartCall(boolean isVideoCall); void onCancelStartCall(); @@ -985,5 +1014,6 @@ public class WebRtcCallView extends ConstraintLayout { void onShowParticipantsList(); void onPageChanged(@NonNull CallParticipantsState.SelectedPage page); void onLocalPictureInPictureClicked(); + void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed); } } 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 7d9c19ac03..3ef9e7f338 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 @@ -9,6 +9,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; @@ -29,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.util.DefaultValueLiveData; import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; @@ -40,32 +42,38 @@ import java.util.Objects; public class WebRtcCallViewModel extends ViewModel { - private final MutableLiveData microphoneEnabled = new MutableLiveData<>(true); - private final MutableLiveData isInPipMode = new MutableLiveData<>(false); - private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE); - private final MutableLiveData foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat()); - private final LiveData controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState); - private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls); - private final SingleLiveEvent events = new SingleLiveEvent(); - private final MutableLiveData elapsed = new MutableLiveData<>(-1L); - private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); - private final MutableLiveData participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE); - private final SingleLiveEvent callParticipantListUpdate = new SingleLiveEvent<>(); - private final MutableLiveData> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList()); - private final LiveData safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new); - private final LiveData groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup); - private final LiveData> groupMembers = LiveDataUtil.skip(Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers())), 1); - private final LiveData shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint); - private final LiveData orientation; - private final MutableLiveData isLandscapeEnabled = new MutableLiveData<>(); - private final LiveData controlsRotation; + private final MutableLiveData microphoneEnabled = new MutableLiveData<>(true); + private final MutableLiveData isInPipMode = new MutableLiveData<>(false); + private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE); + private final MutableLiveData foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat()); + private final LiveData controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState); + private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls); + private final SingleLiveEvent events = new SingleLiveEvent<>(); + private final MutableLiveData elapsed = new MutableLiveData<>(-1L); + private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); + private final DefaultValueLiveData participantsState = new DefaultValueLiveData<>(CallParticipantsState.STARTING_STATE); + private final SingleLiveEvent callParticipantListUpdate = new SingleLiveEvent<>(); + private final MutableLiveData> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList()); + private final LiveData safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new); + private final LiveData groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup); + private final LiveData> groupMembers = Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers())); + private final LiveData> groupMembersChanged = LiveDataUtil.skip(groupMembers, 1); + private final LiveData groupMemberCount = Transformations.map(groupMembers, List::size); + private final LiveData shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint); + private final LiveData orientation; + private final MutableLiveData isLandscapeEnabled = new MutableLiveData<>(); + private final LiveData controlsRotation; + private final Observer> groupMemberStateUpdater = m -> participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), m)); + + private final Handler elapsedTimeHandler = new Handler(Looper.getMainLooper()); + private final Runnable elapsedTimeRunnable = this::handleTick; + private final Runnable stopOutgoingRingingMode = this::stopOutgoingRingingMode; private boolean canDisplayTooltipIfNeeded = true; private boolean hasEnabledLocalVideo = false; + private boolean wasInOutgoingRingingMode = false; private long callConnectedTime = -1; - private Handler elapsedTimeHandler = new Handler(Looper.getMainLooper()); private boolean answerWithVideoAvailable = false; - private Runnable elapsedTimeRunnable = this::handleTick; private boolean canEnterPipMode = false; private List previousParticipantsList = Collections.emptyList(); private boolean callStarting = false; @@ -79,6 +87,8 @@ public class WebRtcCallViewModel extends ViewModel { controlsRotation = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(isLandscapeEnabled), Transformations.distinctUntilChanged(orientation), this::resolveRotation); + + groupMembers.observeForever(groupMemberStateUpdater); } public LiveData getControlsRotation() { @@ -135,8 +145,12 @@ public class WebRtcCallViewModel extends ViewModel { return safetyNumberChangeEvent; } - public LiveData> getGroupMembers() { - return groupMembers; + public LiveData> getGroupMembersChanged() { + return groupMembersChanged; + } + + public LiveData getGroupMemberCount() { + return groupMemberCount; } public LiveData shouldShowSpeakerHint() { @@ -159,7 +173,6 @@ public class WebRtcCallViewModel extends ViewModel { public void setIsInPipMode(boolean isInPipMode) { this.isInPipMode.setValue(isInPipMode); - //noinspection ConstantConditions participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), isInPipMode)); } @@ -174,11 +187,11 @@ public class WebRtcCallViewModel extends ViewModel { } CallParticipantsState state = participantsState.getValue(); - if (state != null && - showScreenShareTip && + if (showScreenShareTip && state.getFocusedParticipant().isScreenSharing() && state.isViewingFocusedParticipant() && - page == CallParticipantsState.SelectedPage.GRID) { + page == CallParticipantsState.SelectedPage.GRID) + { showScreenShareTip = false; events.setValue(new Event.ShowSwipeToSpeakerHint()); } @@ -211,15 +224,14 @@ public class WebRtcCallViewModel extends ViewModel { microphoneEnabled.setValue(localParticipant.isMicrophoneEnabled()); - CallParticipantsState state = participantsState.getValue(); - if (state != null) { - boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing(); - CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo); - participantsState.setValue(newState); - if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) { - switchOnFirstScreenShare = false; - events.setValue(new Event.SwitchToSpeaker()); - } + CallParticipantsState state = participantsState.getValue(); + boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing(); + CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo); + + participantsState.setValue(newState); + if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) { + switchOnFirstScreenShare = false; + events.setValue(new Event.SwitchToSpeaker()); } if (webRtcViewModel.getGroupState().isConnected()) { @@ -245,12 +257,20 @@ public class WebRtcCallViewModel extends ViewModel { webRtcViewModel.getRemoteDevicesCount().orElse(0), webRtcViewModel.getParticipantLimit()); - if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) { - callConnectedTime = webRtcViewModel.getCallConnectedTime(); - startTimer(); - } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) { + if (newState.isInOutgoingRingingMode()) { cancelTimer(); - callConnectedTime = -1; + if (!wasInOutgoingRingingMode) { + elapsedTimeHandler.postDelayed(stopOutgoingRingingMode, CallParticipantsState.MAX_OUTGOING_GROUP_RING_DURATION); + } + wasInOutgoingRingingMode = true; + } else { + if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) { + callConnectedTime = wasInOutgoingRingingMode ? System.currentTimeMillis() : webRtcViewModel.getCallConnectedTime(); + startTimer(); + } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) { + cancelTimer(); + callConnectedTime = -1; + } } if (localParticipant.getCameraState().isEnabled()) { @@ -371,18 +391,26 @@ public class WebRtcCallViewModel extends ViewModel { } private boolean shouldShowSpeakerHint(@NonNull CallParticipantsState state) { - return !state.isInPipMode() && + return !state.isInPipMode() && state.getRemoteDevicesCount().orElse(0) > 1 && - state.getGroupCallState().isConnected() && + state.getGroupCallState().isConnected() && !SignalStore.tooltips().hasSeenGroupCallSpeakerView(); } private void startTimer() { cancelTimer(); + elapsedTimeHandler.removeCallbacks(stopOutgoingRingingMode); elapsedTimeHandler.post(elapsedTimeRunnable); } + private void stopOutgoingRingingMode() { + if (callConnectedTime == -1) { + callConnectedTime = System.currentTimeMillis(); + startTimer(); + } + } + private void handleTick() { if (callConnectedTime == -1) { return; @@ -403,6 +431,7 @@ public class WebRtcCallViewModel extends ViewModel { protected void onCleared() { super.onCleared(); cancelTimer(); + groupMembers.removeObserver(groupMemberStateUpdater); } public void startCall(boolean isVideoCall) { 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 66b137aedb..ab75a810cb 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 @@ -8,6 +8,7 @@ import androidx.annotation.Px; import androidx.annotation.StringRes; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.FeatureFlags; public final class WebRtcControls { @@ -183,6 +184,10 @@ public final class WebRtcControls { return isPreJoin() || isIncoming(); } + boolean displayRingToggle() { + return FeatureFlags.groupCallRinging() && isPreJoin() && isGroupCall() && !hasAtLeastOneRemote; + } + private boolean isError() { return callState == CallState.ERROR; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 19f3a40b59..5d0b4081e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -72,6 +72,7 @@ public class DatabaseFactory { private final EmojiSearchDatabase emojiSearchDatabase; private final MessageSendLogDatabase messageSendLogDatabase; private final AvatarPickerDatabase avatarPickerDatabase; + private final GroupCallRingDatabase groupCallRingDatabase; public static DatabaseFactory getInstance(Context context) { if (instance == null) { @@ -206,6 +207,10 @@ public class DatabaseFactory { return getInstance(context).avatarPickerDatabase; } + public static GroupCallRingDatabase getGroupCallRingDatabase(Context context) { + return getInstance(context).groupCallRingDatabase; + } + public static net.zetetic.database.sqlcipher.SQLiteDatabase getBackupDatabase(Context context) { return getInstance(context).databaseHelper.getRawReadableDatabase(); } @@ -268,6 +273,7 @@ public class DatabaseFactory { this.emojiSearchDatabase = new EmojiSearchDatabase(context, databaseHelper); this.messageSendLogDatabase = new MessageSendLogDatabase(context, databaseHelper); this.avatarPickerDatabase = new AvatarPickerDatabase(context, databaseHelper); + this.groupCallRingDatabase = new GroupCallRingDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupCallRingDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupCallRingDatabase.kt new file mode 100644 index 0000000000..10e08ef507 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupCallRingDatabase.kt @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import org.signal.ringrtc.CallManager +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.util.CursorUtil +import org.thoughtcrime.securesms.util.SqlUtil +import java.util.concurrent.TimeUnit + +/** + * Track state of Group Call ring cancellations. + */ +class GroupCallRingDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Database(context, databaseHelper) { + + companion object { + private val VALID_RING_DURATION = TimeUnit.MINUTES.toMillis(30) + + private const val TABLE_NAME = "group_call_ring" + + private const val ID = "_id" + private const val RING_ID = "ring_id" + private const val DATE_RECEIVED = "date_received" + private const val RING_STATE = "ring_state" + + @JvmField + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $RING_ID INTEGER UNIQUE, + $DATE_RECEIVED INTEGER, + $RING_STATE INTEGER + ) + """.trimIndent() + + @JvmField + val CREATE_INDEXES = arrayOf( + "CREATE INDEX date_received_index on $TABLE_NAME ($DATE_RECEIVED)" + ) + } + + fun isCancelled(ringId: Long): Boolean { + val db = databaseHelper.signalReadableDatabase + + db.query(TABLE_NAME, null, "$RING_ID = ?", SqlUtil.buildArgs(ringId), null, null, null).use { cursor -> + if (cursor.moveToFirst()) { + return CursorUtil.requireInt(cursor, RING_STATE) != 0 + } + } + + return false + } + + fun insertGroupRing(ringId: Long, dateReceived: Long, ringState: CallManager.RingUpdate) { + val db = databaseHelper.signalWritableDatabase + val values = ContentValues().apply { + put(RING_ID, ringId) + put(DATE_RECEIVED, dateReceived) + put(RING_STATE, ringState.toCode()) + } + db.insert(TABLE_NAME, null, values) + + removeOldRings() + } + + fun insertOrUpdateGroupRing(ringId: Long, dateReceived: Long, ringState: CallManager.RingUpdate) { + val db = databaseHelper.signalWritableDatabase + val values = ContentValues().apply { + put(RING_ID, ringId) + put(DATE_RECEIVED, dateReceived) + put(RING_STATE, ringState.toCode()) + } + db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE) + + removeOldRings() + } + + fun removeOldRings() { + val db = databaseHelper.signalWritableDatabase + + db.delete(TABLE_NAME, "$DATE_RECEIVED < ?", SqlUtil.buildArgs(System.currentTimeMillis() - VALID_RING_DURATION)) + } +} + +private fun CallManager.RingUpdate.toCode(): Int { + return when (this) { + CallManager.RingUpdate.REQUESTED -> 0 + CallManager.RingUpdate.EXPIRED_REQUEST -> 1 + CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE -> 2 + CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> 3 + CallManager.RingUpdate.BUSY_LOCALLY -> 4 + CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE -> 5 + CallManager.RingUpdate.CANCELLED_BY_RINGER -> 6 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index f517dfd859..d468c5de4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.ChatColorsDatabase; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase; +import org.thoughtcrime.securesms.database.GroupCallRingDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase; @@ -212,8 +213,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab private static final int THREAD_CLEANUP = 112; private static final int SESSION_MIGRATION = 113; private static final int IDENTITY_MIGRATION = 114; + private static final int GROUP_CALL_RING_TABLE = 115; - private static final int DATABASE_VERSION = 114; + private static final int DATABASE_VERSION = 115; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -255,6 +257,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL(ChatColorsDatabase.CREATE_TABLE); db.execSQL(EmojiSearchDatabase.CREATE_TABLE); db.execSQL(AvatarPickerDatabase.CREATE_TABLE); + db.execSQL(GroupCallRingDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); executeStatements(db, MessageSendLogDatabase.CREATE_TABLE); @@ -272,6 +275,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab executeStatements(db, MentionDatabase.CREATE_INDEXES); executeStatements(db, PaymentDatabase.CREATE_INDEXES); executeStatements(db, MessageSendLogDatabase.CREATE_INDEXES); + executeStatements(db, GroupCallRingDatabase.CREATE_INDEXES); executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS); @@ -2016,6 +2020,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab Log.d(TAG, "Identity migration took " + (System.currentTimeMillis() - start) + " ms"); } + if (oldVersion < GROUP_CALL_RING_TABLE) { + db.execSQL("CREATE TABLE group_call_ring (_id INTEGER PRIMARY KEY, ring_id INTEGER UNIQUE, date_received INTEGER, ring_state INTEGER)"); + db.execSQL("CREATE INDEX date_received_index on group_call_ring (date_received)"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java index 8d09c534fa..f268bec319 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java @@ -58,6 +58,7 @@ public class WebRtcViewModel { public enum GroupCallState { IDLE, + RINGING, DISCONNECTED, CONNECTING, RECONNECTING, @@ -65,6 +66,10 @@ public class WebRtcViewModel { CONNECTED_AND_JOINING, CONNECTED_AND_JOINED; + public boolean isIdle() { + return this == IDLE; + } + public boolean isNotIdle() { return this != IDLE; } @@ -90,6 +95,10 @@ public class WebRtcViewModel { return false; } + + public boolean isRinging() { + return this == RINGING; + } } private final @NonNull State state; @@ -105,6 +114,8 @@ public class WebRtcViewModel { 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(); @@ -117,6 +128,8 @@ public class WebRtcViewModel { 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(), @@ -167,10 +180,22 @@ public class WebRtcViewModel { 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{" + diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java index ba07e3b09f..6aa1bd0a90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -34,6 +34,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.internal.push.exceptions.InvalidUnidentifiedAccessHeaderException; @@ -122,6 +123,22 @@ public final class GroupSendUtil { return sendMessage(context, groupId, allTargets, false, new TypingSendOperation(message), cancelationSignal); } + /** + * Handles all of the logic of sending to a group. Will do sender key sends and legacy 1:1 sends as-needed, and give you back a list of + * {@link SendMessageResult}s just like we're used to. + * + * @param groupId The groupId of the group you're sending to, or null if you're sending to a collection of recipients not joined by a group. + */ + @WorkerThread + public static List sendCallMessage(@NonNull Context context, + @Nullable GroupId.V2 groupId, + @NonNull List allTargets, + @NonNull SignalServiceCallMessage message) + throws IOException, UntrustedIdentityException + { + return sendMessage(context, groupId, allTargets, false, new CallSendOperation(message), null); + } + /** * Handles all of the logic of sending to a group. Will do sender key sends and legacy 1:1 sends as-needed, and give you back a list of * {@link SendMessageResult}s just like we're used to. @@ -434,6 +451,58 @@ public final class GroupSendUtil { } } + private static class CallSendOperation implements SendOperation { + + private final SignalServiceCallMessage message; + + private CallSendOperation(@NonNull SignalServiceCallMessage message) { + this.message = message; + } + + @Override + public @NonNull List sendWithSenderKey(@NonNull SignalServiceMessageSender messageSender, + @NonNull DistributionId distributionId, + @NonNull List targets, + @NonNull List access, + boolean isRecipientUpdate) + throws NoSessionException, UntrustedIdentityException, InvalidKeyException, IOException + { + return messageSender.sendCallMessage(distributionId, targets, access, message); + } + + @Override + public @NonNull List sendLegacy(@NonNull SignalServiceMessageSender messageSender, + @NonNull List targets, + @NonNull List> access, + boolean isRecipientUpdate, + @Nullable PartialSendCompleteListener partialListener, + @Nullable CancelationSignal cancelationSignal) + throws IOException + { + return messageSender.sendCallMessage(targets, access, message); + } + + @Override + public @NonNull ContentHint getContentHint() { + return ContentHint.IMPLICIT; + } + + @Override + public long getSentTimestamp() { + return message.getTimestamp().get(); + } + + @Override + public boolean shouldIncludeInMessageLog() { + return false; + } + + @Override + public @NonNull MessageId getRelatedMessageId() { + throw new UnsupportedOperationException(); + } + } + /** * Little utility wrapper that lets us get the various different slices of recipient models that we need for different methods. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java index 91f87a697c..2d3916f66a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.notifications; import android.Manifest; +import android.annotation.SuppressLint; import android.app.NotificationManager; import android.content.Context; import android.database.Cursor; @@ -28,6 +29,23 @@ public final class DoNotDisturbUtil { } @WorkerThread + @SuppressLint("SwitchIntDef") + public static boolean shouldDisturbUserWithCall(@NonNull Context context) { + if (Build.VERSION.SDK_INT <= 23) return true; + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + + switch (notificationManager.getCurrentInterruptionFilter()) { + case NotificationManager.INTERRUPTION_FILTER_ALL: + case NotificationManager.INTERRUPTION_FILTER_UNKNOWN: + return true; + default: + return false; + } + } + + @WorkerThread + @SuppressLint("SwitchIntDef") public static boolean shouldDisturbUserWithCall(@NonNull Context context, @NonNull Recipient recipient) { if (Build.VERSION.SDK_INT <= 23) return true; @@ -91,6 +109,7 @@ public final class DoNotDisturbUtil { return false; } + //noinspection ConstantConditions try (Cursor cursor = context.getContentResolver().query(recipient.resolve().getContactUri(), new String[]{ContactsContract.Contacts.STARRED}, null, null, null)) { if (cursor == null || !cursor.moveToFirst()) return false; return CursorUtil.requireInt(cursor, ContactsContract.Contacts.STARRED) == 1; diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java index 589fa6770f..bb108595b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupActionProcessor.java @@ -10,7 +10,6 @@ import org.signal.core.util.logging.Log; import org.signal.ringrtc.CallException; import org.signal.ringrtc.GroupCall; import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; -import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.CallParticipantId; import org.thoughtcrime.securesms.events.WebRtcViewModel; @@ -21,13 +20,11 @@ import org.thoughtcrime.securesms.ringrtc.RemotePeer; 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.webrtc.locks.LockManager; import org.webrtc.VideoTrack; -import org.whispersystems.libsignal.IdentityKey; -import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -186,40 +183,37 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { } @Override - protected @NonNull WebRtcServiceState handleReceivedOpaqueMessage(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.OpaqueMessageMetadata opaqueMessageMetadata) { - Log.i(tag, "handleReceivedOpaqueMessage():"); + protected @NonNull WebRtcServiceState handleSetRingGroup(@NonNull WebRtcServiceState currentState, boolean ringGroup) { + Log.i(tag, "handleReceivedOpaqueMessage(): ring: " + ringGroup); - try { - webRtcInteractor.getCallManager().receivedCallMessage(opaqueMessageMetadata.getUuid(), - opaqueMessageMetadata.getRemoteDeviceId(), - 1, - opaqueMessageMetadata.getOpaque(), - opaqueMessageMetadata.getMessageAgeSeconds()); - } catch (CallException e) { - return groupCallFailure(currentState, "Unable to receive opaque message", e); + if (currentState.getCallSetupState().shouldRingGroup() == ringGroup) { + return currentState; } - return currentState; + return currentState.builder() + .changeCallSetupState() + .setRingGroup(ringGroup) + .build(); } @Override protected @NonNull WebRtcServiceState handleGroupMessageSentError(@NonNull WebRtcServiceState currentState, - @NonNull RemotePeer remotePeer, - @NonNull WebRtcViewModel.State errorCallState, - @NonNull Optional identityKey) + @NonNull Collection recipientIds, + @NonNull WebRtcViewModel.State errorCallState) { Log.w(tag, "handleGroupMessageSentError(): error: " + errorCallState); if (errorCallState == WebRtcViewModel.State.UNTRUSTED_IDENTITY) { return currentState.builder() .changeCallInfoState() - .addIdentityChangedRecipient(remotePeer.getId()) + .addIdentityChangedRecipients(recipientIds) .build(); } return currentState; } + @Override protected @NonNull WebRtcServiceState handleGroupApproveSafetyNumberChange(@NonNull WebRtcServiceState currentState, @NonNull List recipientIds) { @@ -291,56 +285,4 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { return terminateGroupCall(currentState); } - - public @NonNull WebRtcServiceState groupCallFailure(@NonNull WebRtcServiceState currentState, @NonNull String message, @NonNull Throwable error) { - Log.w(tag, "groupCallFailure(): " + message, error); - - GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); - Recipient recipient = currentState.getCallInfoState().getCallRecipient(); - - if (recipient != null && currentState.getCallInfoState().getGroupCallState().isConnected()) { - webRtcInteractor.sendGroupCallMessage(recipient, WebRtcUtil.getGroupCallEraId(groupCall)); - } - - currentState = currentState.builder() - .changeCallInfoState() - .callState(WebRtcViewModel.State.CALL_DISCONNECTED) - .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) - .build(); - - webRtcInteractor.postStateUpdate(currentState); - - try { - if (groupCall != null) { - groupCall.disconnect(); - } - webRtcInteractor.getCallManager().reset(); - } catch (CallException e) { - Log.w(tag, "Unable to reset call manager: ", e); - } - - return terminateGroupCall(currentState); - } - - public synchronized @NonNull WebRtcServiceState terminateGroupCall(@NonNull WebRtcServiceState currentState) { - return terminateGroupCall(currentState, true); - } - - public synchronized @NonNull WebRtcServiceState terminateGroupCall(@NonNull WebRtcServiceState currentState, boolean terminateVideo) { - webRtcInteractor.updatePhoneState(LockManager.PhoneState.PROCESSING); - boolean playDisconnectSound = currentState.getCallInfoState().getCallState() == WebRtcViewModel.State.CALL_DISCONNECTED; - webRtcInteractor.stopAudio(playDisconnectSound); - webRtcInteractor.setWantsBluetoothConnection(false); - webRtcInteractor.stopForegroundService(); - - webRtcInteractor.updatePhoneState(LockManager.PhoneState.IDLE); - - if (terminateVideo) { - WebRtcVideoUtil.deinitializeVideo(currentState); - } - - GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(context, currentState.getCallInfoState().getCallRecipient()); - - return new WebRtcServiceState(new IdleActionProcessor(webRtcInteractor)); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupCallRingCheckInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupCallRingCheckInfo.kt new file mode 100644 index 0000000000..406e241b55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupCallRingCheckInfo.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.service.webrtc + +import org.signal.ringrtc.CallManager +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.RecipientId +import java.util.UUID + +data class GroupCallRingCheckInfo( + val recipientId: RecipientId, + val groupId: GroupId.V2, + val ringId: Long, + val ringerUuid: UUID, + val ringUpdate: CallManager.RingUpdate +) 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 e63f0bfc6e..8823eb197c 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,5 +1,7 @@ package org.thoughtcrime.securesms.service.webrtc; +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_ESTABLISHED; + import android.os.ResultReceiver; import androidx.annotation.NonNull; @@ -12,11 +14,10 @@ import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.ringrtc.Camera; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder; +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. */ @@ -81,6 +82,14 @@ public class GroupJoiningActionProcessor extends GroupActionProcessor { throw new RuntimeException(e); } + if (FeatureFlags.groupCallRinging() && currentState.getCallSetupState().shouldRingGroup()) { + try { + groupCall.ringAll(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to ring group", e); + } + } + builder.changeCallInfoState() .callState(WebRtcViewModel.State.CALL_CONNECTED) .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED) @@ -89,8 +98,7 @@ public class GroupJoiningActionProcessor extends GroupActionProcessor { .changeLocalDeviceState() .wantsBluetooth(true) .commit() - .actionProcessor(new GroupConnectedActionProcessor(webRtcInteractor)) - .build(); + .actionProcessor(new GroupConnectedActionProcessor(webRtcInteractor)); } else if (device.getJoinState() == GroupCall.JoinState.JOINING) { builder.changeCallInfoState() .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java index 69e70e559e..86a018cc5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java @@ -3,15 +3,17 @@ package org.thoughtcrime.securesms.service.webrtc; import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.events.WebRtcViewModel; -import org.thoughtcrime.securesms.ringrtc.Camera; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; -import org.webrtc.CapturerObserver; -import org.webrtc.VideoFrame; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import java.util.UUID; + /** * Action handler for when the system is at rest. Mainly responsible * for starting pre-call state, starting an outgoing call, or receiving an @@ -66,4 +68,59 @@ public class IdleActionProcessor extends WebRtcActionProcessor { return isGroupCall ? currentState.getActionProcessor().handlePreJoinCall(currentState, remotePeer) : currentState; } + + @Override + protected @NonNull WebRtcServiceState handleGroupCallRingUpdate(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeerGroup, + @NonNull GroupId.V2 groupId, + long ringId, + @NonNull UUID uuid, + @NonNull CallManager.RingUpdate ringUpdate) + { + Log.i(TAG, "handleGroupCallRingUpdate(): recipient: " + remotePeerGroup.getId() + " ring: " + ringId + " update: " + ringUpdate); + + if (ringUpdate != CallManager.RingUpdate.REQUESTED) { + DatabaseFactory.getGroupCallRingDatabase(context).insertOrUpdateGroupRing(ringId, System.currentTimeMillis(), ringUpdate); + return currentState; + } else if (DatabaseFactory.getGroupCallRingDatabase(context).isCancelled(ringId)) { + try { + Log.i(TAG, "Incoming ring request for already cancelled ring: " + ringId); + webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, null); + } catch (CallException e) { + Log.w(TAG, "Error while trying to cancel ring: " + ringId, e); + } + return currentState; + } + + webRtcInteractor.peekGroupCallForRingingCheck(new GroupCallRingCheckInfo(remotePeerGroup.getId(), groupId, ringId, uuid, ringUpdate)); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleReceivedGroupCallPeekForRingingCheck(@NonNull WebRtcServiceState currentState, @NonNull GroupCallRingCheckInfo info, long deviceCount) { + Log.i(tag, "handleReceivedGroupCallPeekForRingingCheck(): recipient: " + info.getRecipientId() + " ring: " + info.getRingId() + " deviceCount: " + deviceCount); + + if (DatabaseFactory.getGroupCallRingDatabase(context).isCancelled(info.getRingId())) { + try { + Log.i(TAG, "Ring was cancelled while getting peek info ring: " + info.getRingId()); + webRtcInteractor.getCallManager().cancelGroupRing(info.getGroupId().getDecodedId(), info.getRingId(), null); + } catch (CallException e) { + Log.w(TAG, "Error while trying to cancel ring: " + info.getRingId(), e); + } + return currentState; + } + + if (deviceCount == 0) { + Log.i(TAG, "No one in the group call, mark as expired and do not ring"); + DatabaseFactory.getGroupCallRingDatabase(context).insertOrUpdateGroupRing(info.getRingId(), System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST); + return currentState; + } + + currentState = currentState.builder() + .actionProcessor(new IncomingGroupCallActionProcessor(webRtcInteractor)) + .build(); + + return currentState.getActionProcessor().handleGroupCallRingUpdate(currentState, new RemotePeer(info.getRecipientId()), info.getGroupId(), info.getRingId(), info.getRingerUuid(), info.getRingUpdate()); + } } 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 new file mode 100644 index 0000000000..fc8224db74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java @@ -0,0 +1,258 @@ +package org.thoughtcrime.securesms.service.webrtc; + +import android.media.AudioManager; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.signal.ringrtc.CallException; +import org.signal.ringrtc.CallManager; +import org.signal.ringrtc.GroupCall; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.CallParticipantId; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.DoNotDisturbUtil; +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; + +import java.util.UUID; + +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_INCOMING_CONNECTING; +import static org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.TYPE_INCOMING_RINGING; + +/** + * Process actions to go from incoming "ringing" group call to joining. By the time this processor + * is running, the group call to ring has been verified to have at least one active device. + */ +public final class IncomingGroupCallActionProcessor extends DeviceAwareActionProcessor { + + private static final String TAG = Log.tag(IncomingGroupCallActionProcessor.class); + + public IncomingGroupCallActionProcessor(WebRtcInteractor webRtcInteractor) { + super(webRtcInteractor, TAG); + } + + @Override + protected @NonNull WebRtcServiceState handleGroupCallRingUpdate(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeerGroup, + @NonNull GroupId.V2 groupId, + long ringId, + @NonNull UUID uuid, + @NonNull CallManager.RingUpdate ringUpdate) + { + Log.i(TAG, "handleGroupCallRingUpdate(): recipient: " + remotePeerGroup.getId() + " ring: " + ringId + " update: " + ringUpdate); + + Recipient recipient = remotePeerGroup.getRecipient(); + boolean updateForCurrentRingId = ringId == currentState.getCallSetupState().getRingId(); + boolean isCurrentlyRinging = currentState.getCallInfoState().getGroupCallState().isRinging(); + + if (DatabaseFactory.getGroupCallRingDatabase(context).isCancelled(ringId)) { + try { + Log.i(TAG, "Ignoring incoming ring request for already cancelled ring: " + ringId); + webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, null); + } catch (CallException e) { + Log.w(TAG, "Error while trying to cancel ring: " + ringId, e); + } + return currentState; + } + + if (ringUpdate != CallManager.RingUpdate.REQUESTED) { + DatabaseFactory.getGroupCallRingDatabase(context).insertOrUpdateGroupRing(ringId, System.currentTimeMillis(), ringUpdate); + + if (updateForCurrentRingId && isCurrentlyRinging) { + Log.i(TAG, "Cancelling current ring: " + ringId); + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .build(); + + webRtcInteractor.postStateUpdate(currentState); + + return terminateGroupCall(currentState); + } else { + return currentState; + } + } + + if (!updateForCurrentRingId && isCurrentlyRinging) { + try { + Log.i(TAG, "Already ringing so reply busy for new ring: " + ringId); + webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, CallManager.RingCancelReason.Busy); + } catch (CallException e) { + Log.w(TAG, "Error while trying to cancel ring: " + ringId, e); + } + return currentState; + } + + if (updateForCurrentRingId) { + Log.i(TAG, "Already ringing for ring: " + ringId); + return currentState; + } + + Log.i(TAG, "Requesting new ring: " + ringId); + + DatabaseFactory.getGroupCallRingDatabase(context).insertGroupRing(ringId, System.currentTimeMillis(), ringUpdate); + + 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); + + boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext()); + if (shouldDisturbUserWithCall) { + boolean started = webRtcInteractor.startWebRtcCallActivityIfPossible(); + if (!started) { + Log.i(TAG, "Unable to start call activity due to OS version or not being in the foreground"); + ApplicationDependencies.getAppForegroundObserver().addListener(webRtcInteractor.getForegroundListener()); + } + } + + webRtcInteractor.initializeAudioForCall(); + if (shouldDisturbUserWithCall && SignalStore.settings().isCallNotificationsEnabled()) { + Uri ringtone = recipient.resolve().getCallRingtone(); + RecipientDatabase.VibrateState vibrateState = recipient.resolve().getCallVibrate(); + + if (ringtone == null) { + ringtone = SignalStore.settings().getCallRingtone(); + } + + 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() + .changeCallSetupState() + .isRemoteVideoOffer(true) + .ringId(ringId) + .ringerRecipient(Recipient.externalPush(context, uuid, null, false)) + .commit() + .changeCallInfoState() + .callRecipient(remotePeerGroup.getRecipient()) + .callState(WebRtcViewModel.State.CALL_INCOMING) + .groupCallState(WebRtcViewModel.GroupCallState.RINGING) + .putParticipant(remotePeerGroup.getRecipient(), + CallParticipant.createRemote(new CallParticipantId(remotePeerGroup.getRecipient()), + remotePeerGroup.getRecipient(), + null, + new BroadcastVideoSink(currentState.getVideoState().getLockableEglBase(), + false, + true, + currentState.getLocalDeviceState().getOrientation().getDegrees()), + true, + false, + 0, + true, + 0, + false, + CallParticipant.DeviceOrdinal.PRIMARY + )) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleAcceptCall(@NonNull WebRtcServiceState currentState, boolean answerWithVideo) { + byte[] groupId = currentState.getCallInfoState().getCallRecipient().requireGroupId().getDecodedId(); + GroupCall groupCall = webRtcInteractor.getCallManager().createGroupCall(groupId, + SignalStore.internalValues().groupCallingServer(), + currentState.getVideoState().getLockableEglBase().require(), + webRtcInteractor.getGroupCallObserver()); + + try { + groupCall.setOutgoingAudioMuted(true); + groupCall.setOutgoingVideoMuted(true); + groupCall.setBandwidthMode(NetworkUtil.getCallingBandwidthMode(context)); + + Log.i(TAG, "Connecting to group call: " + currentState.getCallInfoState().getCallRecipient().getId()); + groupCall.connect(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to connect to group call", e); + } + + currentState = currentState.builder() + .changeCallInfoState() + .groupCall(groupCall) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .commit() + .changeCallSetupState() + .enableVideoOnCreate(answerWithVideo) + .build(); + + AudioManager androidAudioManager = ServiceUtil.getAudioManager(context); + androidAudioManager.setSpeakerphoneOn(false); + + 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()); + groupCall.setOutgoingVideoMuted(answerWithVideo); + groupCall.setOutgoingAudioMuted(!currentState.getLocalDeviceState().isMicrophoneEnabled()); + groupCall.setBandwidthMode(NetworkUtil.getCallingBandwidthMode(context)); + + groupCall.join(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to join group call", e); + } + + return currentState.builder() + .actionProcessor(new GroupJoiningActionProcessor(webRtcInteractor)) + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_OUTGOING) + .groupCallState(WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING) + .commit() + .changeLocalDeviceState() + .wantsBluetooth(true) + .build(); + } + + @Override + protected @NonNull WebRtcServiceState handleDenyCall(@NonNull WebRtcServiceState currentState) { + Recipient recipient = currentState.getCallInfoState().getCallRecipient(); + Optional groupId = recipient.getGroupId(); + long ringId = currentState.getCallSetupState().getRingId(); + + DatabaseFactory.getGroupCallRingDatabase(context).insertOrUpdateGroupRing(ringId, + System.currentTimeMillis(), + CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE); + + try { + webRtcInteractor.getCallManager().cancelGroupRing(groupId.get().getDecodedId(), + ringId, + CallManager.RingCancelReason.DeclinedByUser); + } catch (CallException e) { + Log.w(TAG, "Error while trying to cancel ring " + ringId, e); + } + + webRtcInteractor.updatePhoneState(LockManager.PhoneState.PROCESSING); + webRtcInteractor.stopAudio(false); + webRtcInteractor.setWantsBluetoothConnection(false); + webRtcInteractor.updatePhoneState(LockManager.PhoneState.IDLE); + webRtcInteractor.stopForegroundService(); + + return WebRtcVideoUtil.deinitializeVideo(currentState) + .builder() + .actionProcessor(new IdleActionProcessor(webRtcInteractor)) + .terminate() + .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 bf0e60e3af..081ac2da86 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,5 +1,11 @@ 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 android.app.Application; import android.content.Context; import android.content.Intent; @@ -21,10 +27,13 @@ import org.signal.ringrtc.GroupCall; import org.signal.ringrtc.HttpHeader; import org.signal.ringrtc.Remote; import org.signal.storageservice.protos.groups.GroupExternalCredential; +import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.GroupIdentifier; import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.GroupCallPeekEvent; import org.thoughtcrime.securesms.events.WebRtcViewModel; @@ -33,6 +42,7 @@ import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.messages.GroupSendUtil; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; @@ -42,7 +52,9 @@ import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.RecipientAccessList; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import org.webrtc.PeerConnection; @@ -51,6 +63,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.calls.CallingResponse; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.OpaqueMessage; @@ -63,16 +76,12 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; - -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 java.util.stream.Collectors; /** * Entry point for all things calling. Lives for the life of the app instance and will spin up a foreground service when needed to @@ -94,6 +103,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. private final LockManager lockManager; private WebRtcServiceState serviceState; + private boolean needsToSetSelfUuid = true; public SignalCallManager(@NonNull Application application) { this.context = application.getApplicationContext(); @@ -136,6 +146,15 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. } serviceExecutor.execute(() -> { + if (needsToSetSelfUuid) { + try { + callManager.setSelfUuid(Recipient.self().requireUuid()); + needsToSetSelfUuid = false; + } catch (CallException e) { + Log.w(TAG, "Unable to set self UUID on CallManager", e); + } + } + Log.v(TAG, "Processing action, handler: " + serviceState.getActionProcessor().getTag()); WebRtcServiceState previous = serviceState; serviceState = action.process(previous, previous.getActionProcessor()); @@ -270,6 +289,14 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleReceivedOpaqueMessage(s, opaqueMessageMetadata)); } + public void setRingGroup(boolean ringGroup) { + process((s, p) -> p.handleSetRingGroup(s, ringGroup)); + } + + private void receivedGroupCallPeekForRingingCheck(@NonNull GroupCallRingCheckInfo groupCallRingCheckInfo, long deviceCount) { + process((s, p) -> p.handleReceivedGroupCallPeekForRingingCheck(s, groupCallRingCheckInfo, deviceCount)); + } + public void peekGroupCall(@NonNull RecipientId id) { if (callManager == null) { Log.i(TAG, "Unable to peekGroupCall, call manager is null"); @@ -307,6 +334,33 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. }); } + public void peekGroupCallForRingingCheck(@NonNull GroupCallRingCheckInfo info) { + if (callManager == null) { + Log.i(TAG, "Unable to peekGroupCall, call manager is null"); + return; + } + + networkExecutor.execute(() -> { + try { + Recipient group = Recipient.resolved(info.getRecipientId()); + GroupId.V2 groupId = group.requireGroupId().requireV2(); + GroupExternalCredential credential = GroupManager.getGroupExternalCredential(context, groupId); + + List members = GroupManager.getUuidCipherTexts(context, groupId) + .entrySet() + .stream() + .map(entry -> new GroupCall.GroupMemberInfo(entry.getKey(), entry.getValue().serialize())) + .collect(Collectors.toList()); + + callManager.peekGroupCall(SignalStore.internalValues().groupCallingServer(), credential.getTokenBytes().toByteArray(), members, peekInfo -> { + receivedGroupCallPeekForRingingCheck(info, peekInfo.getDeviceCount()); + }); + } catch (IOException | VerificationFailedException | CallException e) { + Log.e(TAG, "error peeking for ringing check", e); + } + }); + } + public boolean startCallCardActivityIfPossible() { if (Build.VERSION.SDK_INT >= 29 && !ApplicationDependencies.getAppForegroundObserver().isForegrounded()) { return false; @@ -535,7 +589,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. } @Override - public void onSendCallMessage(@NonNull final UUID uuid, @NonNull final byte[] bytes) { + public void onSendCallMessage(@NonNull UUID uuid, @NonNull byte[] bytes, @NonNull CallManager.CallMessageUrgency unused) { Log.i(TAG, "onSendCallMessage():"); OpaqueMessage opaqueMessage = new OpaqueMessage(bytes); @@ -553,10 +607,49 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. } catch (UntrustedIdentityException e) { Log.i(TAG, "sendOpaqueCallMessage onFailure: ", e); RetrieveProfileJob.enqueue(recipient.getId()); - process((s, p) -> p.handleGroupMessageSentError(s, new RemotePeer(recipient.getId()), UNTRUSTED_IDENTITY, Optional.fromNullable(e.getIdentityKey()))); + process((s, p) -> p.handleGroupMessageSentError(s, Collections.singletonList(recipient.getId()), UNTRUSTED_IDENTITY)); } catch (IOException e) { Log.i(TAG, "sendOpaqueCallMessage onFailure: ", e); - process((s, p) -> p.handleGroupMessageSentError(s, new RemotePeer(recipient.getId()), NETWORK_FAILURE, Optional.absent())); + process((s, p) -> p.handleGroupMessageSentError(s, Collections.singletonList(recipient.getId()), NETWORK_FAILURE)); + } + }); + } + + @Override + public void onSendCallMessageToGroup(@NonNull byte[] groupIdBytes, @NonNull byte[] message, @NonNull CallManager.CallMessageUrgency unused) { + Log.i(TAG, "onSendCallMessageToGroup():"); + + networkExecutor.execute(() -> { + try { + GroupId groupId = GroupId.v2(new GroupIdentifier(groupIdBytes)); + List recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + + recipients = RecipientUtil.getEligibleForSending((recipients.stream() + .map(Recipient::resolve) + .filter(r -> !r.isBlocked()) + .collect(Collectors.toList()))); + + OpaqueMessage opaqueMessage = new OpaqueMessage(message); + SignalServiceCallMessage callMessage = SignalServiceCallMessage.forOutgoingGroupOpaque(groupId.getDecodedId(), System.currentTimeMillis(), opaqueMessage, true, null); + RecipientAccessList accessList = new RecipientAccessList(recipients); + + List results = GroupSendUtil.sendCallMessage(context, + groupId.requireV2(), + recipients, + callMessage); + + Set identifyFailureRecipientIds = results.stream() + .filter(result -> result.getIdentityFailure() != null) + .map(result -> accessList.requireIdByAddress(result.getAddress())) + .collect(Collectors.toSet()); + + if (Util.hasItems(identifyFailureRecipientIds)) { + process((s, p) -> p.handleGroupMessageSentError(s, identifyFailureRecipientIds, UNTRUSTED_IDENTITY)); + + RetrieveProfileJob.enqueue(identifyFailureRecipientIds); + } + } catch (UntrustedIdentityException | IOException | InvalidInputException e) { + Log.w(TAG, "onSendCallMessageToGroup failed", e); } }); } @@ -594,6 +687,22 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. }); } + @Override + public void onGroupCallRingUpdate(@NonNull byte[] groupIdBytes, long ringId, @NonNull UUID uuid, @NonNull CallManager.RingUpdate ringUpdate) { + try { + GroupId.V2 groupId = GroupId.v2(new GroupIdentifier(groupIdBytes)); + Optional group = DatabaseFactory.getGroupDatabase(context).getGroup(groupId); + + if (group.isPresent()) { + process((s, p) -> p.handleGroupCallRingUpdate(s, new RemotePeer(group.get().getRecipientId()), groupId, ringId, uuid, ringUpdate)); + } else { + Log.w(TAG, "Unable to ring unknown group."); + } + } catch (InvalidInputException e) { + Log.w(TAG, "Unable to ring group due to invalid group id", e); + } + } + @Override public void requestMembershipProof(@NonNull final GroupCall groupCall) { Log.i(TAG, "requestMembershipProof():"); @@ -662,8 +771,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. @Override public void onForeground() { process((s, p) -> { - WebRtcViewModel.State callState = s.getCallInfoState().getCallState(); - if (callState == CALL_INCOMING && s.getCallInfoState().getGroupCallState() == IDLE) { + WebRtcViewModel.State callState = s.getCallInfoState().getCallState(); + WebRtcViewModel.GroupCallState groupCallState = s.getCallInfoState().getGroupCallState(); + + if (callState == CALL_INCOMING && (groupCallState == IDLE || groupCallState.isRinging())) { startCallCardActivityIfPossible(); } ApplicationDependencies.getAppForegroundObserver().removeListener(this); 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 1715500c11..2df62b97ba 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,5 +1,9 @@ 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; @@ -10,14 +14,18 @@ import org.signal.core.util.logging.Log; import org.signal.ringrtc.CallException; import org.signal.ringrtc.CallId; import org.signal.ringrtc.CallManager; +import org.signal.ringrtc.CallManager.RingUpdate; import org.signal.ringrtc.GroupCall; -import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.sensors.Orientation; import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil; import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.ringrtc.CallState; @@ -41,13 +49,10 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import java.util.Collection; import java.util.List; import java.util.Objects; - -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.OpaqueMessageMetadata; -import static org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedAnswerMetadata; +import java.util.UUID; /** * Base WebRTC action processor and core of the calling state machine. As actions (as intents) @@ -616,9 +621,8 @@ public abstract class WebRtcActionProcessor { } protected @NonNull WebRtcServiceState handleGroupMessageSentError(@NonNull WebRtcServiceState currentState, - @NonNull RemotePeer remotePeer, - @NonNull WebRtcViewModel.State errorCallState, - @NonNull Optional identityKey) + @NonNull Collection recipientIds, + @NonNull WebRtcViewModel.State errorCallState) { Log.i(tag, "handleGroupMessageSentError not processed"); return currentState; @@ -631,11 +635,106 @@ public abstract class WebRtcActionProcessor { return currentState; } - //endregion + protected @NonNull WebRtcServiceState handleReceivedOpaqueMessage(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.OpaqueMessageMetadata opaqueMessageMetadata) { + Log.i(tag, "handleReceivedOpaqueMessage():"); - protected @NonNull WebRtcServiceState handleReceivedOpaqueMessage(@NonNull WebRtcServiceState currentState, @NonNull OpaqueMessageMetadata opaqueMessageMetadata) { - Log.i(tag, "handleReceivedOpaqueMessage not processed"); + try { + webRtcInteractor.getCallManager().receivedCallMessage(opaqueMessageMetadata.getUuid(), + opaqueMessageMetadata.getRemoteDeviceId(), + 1, + opaqueMessageMetadata.getOpaque(), + opaqueMessageMetadata.getMessageAgeSeconds()); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to receive opaque message", e); + } return currentState; } + + protected @NonNull WebRtcServiceState handleGroupCallRingUpdate(@NonNull WebRtcServiceState currentState, + @NonNull RemotePeer remotePeerGroup, + @NonNull GroupId.V2 groupId, + long ringId, + @NonNull UUID uuid, + @NonNull RingUpdate ringUpdate) + { + Log.i(tag, "handleGroupCallRingUpdate(): recipient: " + remotePeerGroup.getId() + " ring: " + ringId + " update: " + ringUpdate); + + try { + if (ringUpdate != RingUpdate.BUSY_LOCALLY && ringUpdate != RingUpdate.BUSY_ON_ANOTHER_DEVICE) { + webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, CallManager.RingCancelReason.Busy); + } + DatabaseFactory.getGroupCallRingDatabase(context).insertOrUpdateGroupRing(ringId, + System.currentTimeMillis(), + ringUpdate == RingUpdate.REQUESTED ? RingUpdate.BUSY_LOCALLY : ringUpdate); + } catch (CallException e) { + Log.w(tag, "Unable to cancel ring", e); + } + return currentState; + } + + protected @NonNull WebRtcServiceState handleSetRingGroup(@NonNull WebRtcServiceState currentState, boolean ringGroup) { + Log.i(tag, "handleSetRingGroup not processed"); + + return currentState; + } + + protected @NonNull WebRtcServiceState handleReceivedGroupCallPeekForRingingCheck(@NonNull WebRtcServiceState currentState, @NonNull GroupCallRingCheckInfo info, long deviceCount) { + Log.i(tag, "handleReceivedGroupCallPeekForRingingCheck not processed"); + + return currentState; + } + + protected @NonNull WebRtcServiceState groupCallFailure(@NonNull WebRtcServiceState currentState, @NonNull String message, @NonNull Throwable error) { + Log.w(tag, "groupCallFailure(): " + message, error); + + GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); + Recipient recipient = currentState.getCallInfoState().getCallRecipient(); + + if (recipient != null && currentState.getCallInfoState().getGroupCallState().isConnected()) { + webRtcInteractor.sendGroupCallMessage(recipient, WebRtcUtil.getGroupCallEraId(groupCall)); + } + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + + webRtcInteractor.postStateUpdate(currentState); + + try { + if (groupCall != null) { + groupCall.disconnect(); + } + webRtcInteractor.getCallManager().reset(); + } catch (CallException e) { + Log.w(tag, "Unable to reset call manager: ", e); + } + + return terminateGroupCall(currentState); + } + + protected synchronized @NonNull WebRtcServiceState terminateGroupCall(@NonNull WebRtcServiceState currentState) { + return terminateGroupCall(currentState, true); + } + + protected synchronized @NonNull WebRtcServiceState terminateGroupCall(@NonNull WebRtcServiceState currentState, boolean terminateVideo) { + 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(); + + if (terminateVideo) { + WebRtcVideoUtil.deinitializeVideo(currentState); + } + + GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(context, currentState.getCallInfoState().getCallRecipient()); + + return new WebRtcServiceState(new IdleActionProcessor(webRtcInteractor)); + } + + //endregion } 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 64695ca9dd..aa264a30e9 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 @@ -45,9 +45,9 @@ public class WebRtcInteractor { @NonNull GroupCall.Observer groupCallObserver, @NonNull AppForegroundObserver.Listener foregroundListener) { - this.context = context; - this.signalCallManager = signalCallManager; - this.lockManager = lockManager; + this.context = context; + this.signalCallManager = signalCallManager; + this.lockManager = lockManager; this.audioManager = audioManager; this.cameraEventListener = cameraEventListener; this.groupCallObserver = groupCallObserver; @@ -154,7 +154,7 @@ public class WebRtcInteractor { audioManager.startCommunication(preserveSpeakerphone); } - void peekGroupCall(@NonNull RecipientId recipientId) { - signalCallManager.peekGroupCall(recipientId); + void peekGroupCallForRingingCheck(@NonNull GroupCallRingCheckInfo groupCallRingCheckInfo) { + signalCallManager.peekGroupCallForRingingCheck(groupCallRingCheckInfo); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.java deleted file mode 100644 index a6167b912d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.thoughtcrime.securesms.service.webrtc.state; - -import androidx.annotation.NonNull; - -/** - * Information specific to setting up a call. - */ -public final class CallSetupState { - boolean enableVideoOnCreate; - boolean isRemoteVideoOffer; - boolean acceptWithVideo; - boolean sentJoinedMessage; - - public CallSetupState() { - this(false, false, false, false); - } - - public CallSetupState(@NonNull CallSetupState toCopy) { - this(toCopy.enableVideoOnCreate, toCopy.isRemoteVideoOffer, toCopy.acceptWithVideo, toCopy.sentJoinedMessage); - } - - public CallSetupState(boolean enableVideoOnCreate, boolean isRemoteVideoOffer, boolean acceptWithVideo, boolean sentJoinedMessage) { - this.enableVideoOnCreate = enableVideoOnCreate; - this.isRemoteVideoOffer = isRemoteVideoOffer; - this.acceptWithVideo = acceptWithVideo; - this.sentJoinedMessage = sentJoinedMessage; - } - - public boolean isEnableVideoOnCreate() { - return enableVideoOnCreate; - } - - public boolean isRemoteVideoOffer() { - return isRemoteVideoOffer; - } - - public boolean isAcceptWithVideo() { - return acceptWithVideo; - } - - public boolean hasSentJoinedMessage() { - return sentJoinedMessage; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt new file mode 100644 index 0000000000..44a3951905 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.service.webrtc.state + +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Information specific to setting up a call. + */ +data class CallSetupState( + var isEnableVideoOnCreate: Boolean = false, + var isRemoteVideoOffer: Boolean = false, + var isAcceptWithVideo: Boolean = false, + @get:JvmName("hasSentJoinedMessage") var sentJoinedMessage: Boolean = false, + @get:JvmName("shouldRingGroup") var ringGroup: Boolean = true, + var ringId: Long = NO_RING, + var ringerRecipient: Recipient = Recipient.UNKNOWN +) { + + fun duplicate(): CallSetupState { + return copy() + } + + companion object { + const val NO_RING = 0L + } +} 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 999ba342c1..5ab6c8c670 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 @@ -25,7 +25,7 @@ public final class WebRtcServiceState { public WebRtcServiceState(@NonNull WebRtcServiceState toCopy) { this.actionProcessor = toCopy.actionProcessor; - this.callSetupState = new CallSetupState(toCopy.callSetupState); + this.callSetupState = toCopy.callSetupState.duplicate(); this.callInfoState = new CallInfoState(toCopy.callInfoState); this.localDeviceState = new LocalDeviceState(toCopy.localDeviceState); 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 e78f2826b0..67292f03c4 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 @@ -126,7 +126,7 @@ public class WebRtcServiceStateBuilder { private CallSetupState toBuild; public CallSetupStateBuilder() { - toBuild = new CallSetupState(WebRtcServiceStateBuilder.this.toBuild.callSetupState); + toBuild = WebRtcServiceStateBuilder.this.toBuild.callSetupState.duplicate(); } public @NonNull WebRtcServiceStateBuilder commit() { @@ -140,22 +140,37 @@ public class WebRtcServiceStateBuilder { } public @NonNull CallSetupStateBuilder enableVideoOnCreate(boolean enableVideoOnCreate) { - toBuild.enableVideoOnCreate = enableVideoOnCreate; + toBuild.setEnableVideoOnCreate(enableVideoOnCreate); return this; } public @NonNull CallSetupStateBuilder isRemoteVideoOffer(boolean isRemoteVideoOffer) { - toBuild.isRemoteVideoOffer = isRemoteVideoOffer; + toBuild.setRemoteVideoOffer(isRemoteVideoOffer); return this; } public @NonNull CallSetupStateBuilder acceptWithVideo(boolean acceptWithVideo) { - toBuild.acceptWithVideo = acceptWithVideo; + toBuild.setAcceptWithVideo(acceptWithVideo); return this; } public @NonNull CallSetupStateBuilder sentJoinedMessage(boolean sentJoinedMessage) { - toBuild.sentJoinedMessage = sentJoinedMessage; + toBuild.setSentJoinedMessage(sentJoinedMessage); + return this; + } + + public @NonNull CallSetupStateBuilder setRingGroup(boolean ringGroup) { + toBuild.setRingGroup(ringGroup); + return this; + } + + public @NonNull CallSetupStateBuilder ringId(long ringId) { + toBuild.setRingId(ringId); + return this; + } + + public @NonNull CallSetupStateBuilder ringerRecipient(@NonNull Recipient ringerRecipient) { + toBuild.setRingerRecipient(ringerRecipient); return this; } } @@ -270,8 +285,8 @@ public class WebRtcServiceStateBuilder { return this; } - public @NonNull CallInfoStateBuilder addIdentityChangedRecipient(@NonNull RecipientId id) { - toBuild.identityChangedRecipients.add(id); + public @NonNull CallInfoStateBuilder addIdentityChangedRecipients(@NonNull Collection id) { + toBuild.identityChangedRecipients.addAll(id); return this; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 72aeae9ad7..92e7b410a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -82,6 +82,8 @@ public final class FeatureFlags { private static final String RETRY_RECEIPTS = "android.retryReceipts"; private static final String SUGGEST_SMS_BLACKLIST = "android.suggestSmsBlacklist"; private static final String ANNOUNCEMENT_GROUPS = "android.announcementGroups"; + private static final String MAX_GROUP_CALL_RING_SIZE = "global.calling.maxGroupCallRingSize"; + private static final String GROUP_CALL_RINGING = "android.calling.groupCallRinging"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -117,7 +119,9 @@ public final class FeatureFlags { SENDER_KEY, RETRY_RECEIPTS, SUGGEST_SMS_BLACKLIST, - ANNOUNCEMENT_GROUPS + ANNOUNCEMENT_GROUPS, + MAX_GROUP_CALL_RING_SIZE, + GROUP_CALL_RINGING ); @VisibleForTesting @@ -166,7 +170,9 @@ public final class FeatureFlags { RETRY_RESPOND_MAX_AGE, SUGGEST_SMS_BLACKLIST, RETRY_RECEIPTS, - SENDER_KEY + SENDER_KEY, + MAX_GROUP_CALL_RING_SIZE, + GROUP_CALL_RINGING ); /** @@ -383,6 +389,16 @@ public final class FeatureFlags { return getString(SUGGEST_SMS_BLACKLIST, ""); } + /** Max group size that can be use group call ringing. */ + public static long maxGroupCallRingSize() { + return getLong(MAX_GROUP_CALL_RING_SIZE, 16); + } + + /** Whether or not to show the group call ring toggle in the UI. */ + public static boolean groupCallRinging() { + return getBoolean(GROUP_CALL_RINGING, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java index 255edc9d73..78b4d4496e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java @@ -52,9 +52,9 @@ public class CallNotificationBuilder { builder.setContentText(context.getString(R.string.CallNotificationBuilder_connecting)); builder.setPriority(NotificationCompat.PRIORITY_MIN); } else if (type == TYPE_INCOMING_RINGING) { - builder.setContentText(context.getString(R.string.NotificationBarManager__incoming_signal_call)); - builder.addAction(getServiceNotificationAction(context, WebRtcCallService.denyCallIntent(context), R.drawable.ic_close_grey600_32dp, R.string.NotificationBarManager__deny_call)); - builder.addAction(getActivityNotificationAction(context, WebRtcCallActivity.ANSWER_ACTION, R.drawable.ic_phone_grey600_32dp, R.string.NotificationBarManager__answer_call)); + builder.setContentText(context.getString(recipient.isGroup() ? R.string.NotificationBarManager__incoming_signal_group_call : R.string.NotificationBarManager__incoming_signal_call)); + builder.addAction(getServiceNotificationAction(context, WebRtcCallService.denyCallIntent(context), R.drawable.ic_close_grey600_32dp, R.string.NotificationBarManager__decline_call)); + builder.addAction(getActivityNotificationAction(context, WebRtcCallActivity.ANSWER_ACTION, R.drawable.ic_phone_grey600_32dp, recipient.isGroup() ? R.string.NotificationBarManager__join_call : R.string.NotificationBarManager__answer_call)); if (callActivityRestricted()) { builder.setFullScreenIntent(pendingIntent, true); diff --git a/app/src/main/res/drawable/ic_ring_28.xml b/app/src/main/res/drawable/ic_ring_28.xml new file mode 100644 index 0000000000..1020b0d922 --- /dev/null +++ b/app/src/main/res/drawable/ic_ring_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_ring_disabled_28.xml b/app/src/main/res/drawable/ic_ring_disabled_28.xml new file mode 100644 index 0000000000..39d75e8799 --- /dev/null +++ b/app/src/main/res/drawable/ic_ring_disabled_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_ring_grey_28.xml b/app/src/main/res/drawable/ic_ring_grey_28.xml new file mode 100644 index 0000000000..94dcf793bb --- /dev/null +++ b/app/src/main/res/drawable/ic_ring_grey_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_grey_disabled.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_grey_disabled.xml new file mode 100644 index 0000000000..348b2a3ffe --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_grey_disabled.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_ring_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_ring_toggle.xml new file mode 100644 index 0000000000..3fd02615da --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_ring_toggle.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_ring_toggle_small.xml b/app/src/main/res/drawable/webrtc_call_screen_ring_toggle_small.xml new file mode 100644 index 0000000000..05aa290dcb --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_ring_toggle_small.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/webrtc_call_view.xml b/app/src/main/res/layout/webrtc_call_view.xml index f286a54e39..f9da0a4c9e 100644 --- a/app/src/main/res/layout/webrtc_call_view.xml +++ b/app/src/main/res/layout/webrtc_call_view.xml @@ -40,7 +40,8 @@ app:layout_constraintBottom_toTopOf="@id/fold_top_guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> + app:layout_constraintTop_toTopOf="parent" + tools:visibility="gone"> + app:layout_constraintTop_toTopOf="parent" + tools:visibility="gone"> @@ -354,6 +357,42 @@ app:layout_constraintVertical_bias="0" tools:visibility="visible" /> + + + + @@ -397,7 +436,7 @@ android:layout_width="match_parent" android:layout_height="1dp" app:barrierDirection="top" - app:constraint_referenced_ids="call_screen_speaker_toggle_label,call_screen_camera_direction_toggle_label,call_screen_video_toggle_label,call_screen_audio_mic_toggle_label,call_screen_end_call_label" /> + app:constraint_referenced_ids="call_screen_speaker_toggle_label,call_screen_camera_direction_toggle_label,call_screen_video_toggle_label,call_screen_audio_mic_toggle_label,call_screen_audio_ring_toggle_label,call_screen_end_call_label" /> + tools:visibility="gone" /> + tools:visibility="gone" /> + tools:visibility="gone" /> + tools:visibility="gone" /> + tools:visibility="gone" /> + tools:visibility="gone" /> + tools:visibility="gone" /> Signal call in progress Establishing Signal call Incoming Signal call + Incoming Signal group call Stopping Signal call service - Deny call + Decline call Answer call End call Cancel call + Join call Turn on Notifications? @@ -1398,6 +1400,7 @@ To call %1$s, Signal needs access to your camera Signal %1$s Calling… + Group is too large to ring the participants. Signal Call @@ -1412,6 +1415,35 @@ Joining… Disconnected + Signal will ring %1$s + Signal will ring %1$s and %2$s + + Signal will ring %1$s, %2$s, and %3$d other + Signal will ring %1$s, %2$s, and %3$d others + + + %1$s will be notified + %1$s and %2$s will be notified + + %1$s, %2$s, and %3$d other will be notified + %1$s, %2$s, and %3$d others will be notified + + + Ringing %1$s + Ringing %1$s and %2$s + + Ringing %1$s, %2$s, and %3$d other + Ringing %1$s, %2$s, and %3$d others + + + %1$s is calling you + %1$s is calling you and %2$s + %1$s is calling you, %2$s, and %3$s + + %1$s is calling you, %2$s, %3$s, and %4$d other + %1$s is calling you, %2$s, %3$s, and %4$d others + + No one else is here %1$s is in this call %1$s are in this call @@ -1427,6 +1459,7 @@ Speaker Camera Mute + Ring End call diff --git a/app/witness-verifications.gradle b/app/witness-verifications.gradle index 1152e7a8e9..511fe5efe9 100644 --- a/app/witness-verifications.gradle +++ b/app/witness-verifications.gradle @@ -558,8 +558,8 @@ dependencyVerification { ['org.signal:argon2:13.1', '0f686ccff0d4842bfcc74d92e8dc780a5f159b9376e37a1189fabbcdac458bef'], - ['org.signal:ringrtc-android:2.10.8', - '4c5b2a80fc905b58c96aa9e1637f47eb68cedf8a5147d25eebe43768487b784e'], + ['org.signal:ringrtc-android:2.11.1', + '74be8f643a85df0a845ea9e9ccd235ece3865a4e380bec4b8ba0a732eaafefc5'], ['org.signal:zkgroup-android:0.7.0', '52b172565bd01526e93ebf1796b834bdc449d4fe3422c1b827e49cb8d4f13fbd'], diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index d0779429cd..f00427620c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -273,6 +273,27 @@ public class SignalServiceMessageSender { sendMessage(recipient, getTargetUnidentifiedAccess(unidentifiedAccess), System.currentTimeMillis(), envelopeContent, false, null); } + public List sendCallMessage(List recipients, + List> unidentifiedAccess, + SignalServiceCallMessage message) + throws IOException + { + Content content = createCallContent(message); + EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, ContentHint.DEFAULT, Optional.absent()); + + return sendMessage(recipients, getTargetUnidentifiedAccess(unidentifiedAccess), System.currentTimeMillis(), envelopeContent, false, null, null); + } + + public List sendCallMessage(DistributionId distributionId, + List recipients, + List unidentifiedAccess, + SignalServiceCallMessage message) + throws IOException, UntrustedIdentityException, InvalidKeyException, NoSessionException + { + Content content = createCallContent(message); + return sendGroupMessage(distributionId, recipients, unidentifiedAccess, message.getTimestamp().get(), content, ContentHint.IMPLICIT, message.getGroupId().get(), false); + } + /** * Send an http request on behalf of the calling infrastructure. * diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/SignalServiceCallMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/SignalServiceCallMessage.java index a15e3f984a..f7b0e215d5 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/SignalServiceCallMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/calls/SignalServiceCallMessage.java @@ -15,6 +15,8 @@ public class SignalServiceCallMessage { private final Optional opaqueMessage; private final Optional destinationDeviceId; private final boolean isMultiRing; + private final Optional groupId; + private final Optional timestamp; private SignalServiceCallMessage(Optional offerMessage, Optional answerMessage, @@ -24,6 +26,20 @@ public class SignalServiceCallMessage { Optional opaqueMessage, boolean isMultiRing, Optional destinationDeviceId) + { + this(offerMessage, answerMessage, iceUpdateMessages, hangupMessage, busyMessage, opaqueMessage, isMultiRing, destinationDeviceId, Optional.absent(), Optional.absent()); + } + + private SignalServiceCallMessage(Optional offerMessage, + Optional answerMessage, + Optional> iceUpdateMessages, + Optional hangupMessage, + Optional busyMessage, + Optional opaqueMessage, + boolean isMultiRing, + Optional destinationDeviceId, + Optional groupId, + Optional timestamp) { this.offerMessage = offerMessage; this.answerMessage = answerMessage; @@ -33,6 +49,8 @@ public class SignalServiceCallMessage { this.opaqueMessage = opaqueMessage; this.isMultiRing = isMultiRing; this.destinationDeviceId = destinationDeviceId; + this.groupId = groupId; + this.timestamp = timestamp; } public static SignalServiceCallMessage forOffer(OfferMessage offerMessage, boolean isMultiRing, Integer destinationDeviceId) { @@ -115,6 +133,19 @@ public class SignalServiceCallMessage { Optional.fromNullable(destinationDeviceId)); } + public static SignalServiceCallMessage forOutgoingGroupOpaque(byte[] groupId, long timestamp, OpaqueMessage opaqueMessage, boolean isMultiRing, Integer destinationDeviceId) { + return new SignalServiceCallMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(opaqueMessage), + isMultiRing, + Optional.fromNullable(destinationDeviceId), + Optional.of(groupId), + Optional.of(timestamp)); + } + public static SignalServiceCallMessage empty() { return new SignalServiceCallMessage(Optional.absent(), @@ -122,7 +153,8 @@ public class SignalServiceCallMessage { Optional.absent(), Optional.absent(), Optional.absent(), - Optional.absent(), false, + Optional.absent(), + false, Optional.absent()); } @@ -157,4 +189,12 @@ public class SignalServiceCallMessage { public Optional getDestinationDeviceId() { return destinationDeviceId; } + + public Optional getGroupId() { + return groupId; + } + + public Optional getTimestamp() { + return timestamp; + } } diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index e4baee4fa0..9f571db20b 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -97,7 +97,8 @@ message CallMessage { } message Opaque { - optional bytes data = 1; + optional bytes data = 1; + reserved /* urgency */ 2; } optional Offer offer = 1;