diff --git a/app/build.gradle b/app/build.gradle index a8577a85a2..321d870b93 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -147,6 +147,7 @@ android { buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\"" buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\"" buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\"" + buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\"" buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"" buildConfigField "int", "CONTENT_PROXY_PORT", "443" buildConfigField "String", "SIGNAL_AGENT", "\"OWA\"" @@ -368,7 +369,7 @@ dependencies { implementation 'com.google.protobuf:protobuf-javalite:3.10.0' implementation 'org.signal:argon2:13.1@aar' - implementation 'org.signal:ringrtc-android:2.8.0' + implementation 'org.signal:ringrtc-android:2.8.3' implementation "me.leolin:ShortcutBadger:1.1.16" implementation 'se.emilsjolander:stickylistheaders:2.7.0' diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index bdaad0ef63..1d9c2ac17e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -60,6 +60,7 @@ public interface BindableConversationItem extends Unbindable { void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position); void onVoiceNoteSeekTo(@NonNull Uri uri, double position); void onGroupMigrationLearnMoreClicked(@NonNull List pendingRecipients); + void onJoinGroupCallClicked(); /** @return true if handled, false if you want to let the normal url handling continue */ boolean onUrlClicked(@NonNull String url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 04e4f15941..6829f0ba07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -207,7 +207,6 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe private void initializeResources() { callScreen = findViewById(R.id.callScreen); callScreen.setControlsListener(new ControlsListener()); - callScreen.setEventListener(new EventListener()); } private void initializeViewModel() { @@ -218,6 +217,17 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe viewModel.getEvents().observe(this, this::handleViewModelEvent); viewModel.getCallTime().observe(this, this::handleCallTime); viewModel.getCallParticipantsState().observe(this, callScreen::updateCallParticipants); + + callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + CallParticipantsState state = viewModel.getCallParticipantsState().getValue(); + if (state != null) { + if (state.needsNewRequestSizes()) { + Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS); + startService(intent); + } + } + }); } private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) { @@ -619,13 +629,4 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe viewModel.setIsViewingFocusedParticipant(page); } } - - private class EventListener implements WebRtcCallView.EventListener { - @Override - public void onPotentialLayoutChange() { - Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); - intent.setAction(WebRtcCallService.ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS); - startService(intent); - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index 09ffe28b16..39a0e36dca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; import org.thoughtcrime.securesms.mms.GlideApp; @@ -132,9 +133,23 @@ public final class AvatarImageView extends AppCompatImageView { setAvatar(GlideApp.with(this), recipient, false); } + /** + * Shows self as the profile avatar. + */ + public void setAvatarUsingProfile(@Nullable Recipient recipient) { + setAvatar(GlideApp.with(this), recipient, false, true); + } + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) { + setAvatar(requestManager, recipient, quickContactEnabled, false); + } + + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar) { if (recipient != null) { - RecipientContactPhoto photo = new RecipientContactPhoto(recipient); + RecipientContactPhoto photo = (recipient.isSelf() && useSelfProfileAvatar) ? new RecipientContactPhoto(recipient, + new ProfileContactPhoto(Recipient.self(), + Recipient.self().getProfileAvatar())) + : new RecipientContactPhoto(recipient); if (!photo.equals(recipientContactPhoto)) { requestManager.clear(this); @@ -218,9 +233,13 @@ public final class AvatarImageView extends AppCompatImageView { private final boolean ready; RecipientContactPhoto(@NonNull Recipient recipient) { + this(recipient, recipient.getContactPhoto()); + } + + RecipientContactPhoto(@NonNull Recipient recipient, @Nullable ContactPhoto contactPhoto) { this.recipient = recipient; this.ready = !recipient.isResolving(); - this.contactPhoto = recipient.getContactPhoto(); + this.contactPhoto = contactPhoto; } public boolean equals(@Nullable RecipientContactPhoto other) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java index dac44919e3..132d7108dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java @@ -17,11 +17,13 @@ public class BroadcastVideoSink implements VideoSink { private final EglBase eglBase; private final WeakHashMap sinks; private final WeakHashMap requestingSizes; + private boolean dirtySizes; public BroadcastVideoSink(@Nullable EglBase eglBase) { this.eglBase = eglBase; this.sinks = new WeakHashMap<>(); this.requestingSizes = new WeakHashMap<>(); + this.dirtySizes = true; } public @Nullable EglBase getEglBase() { @@ -46,12 +48,14 @@ public class BroadcastVideoSink implements VideoSink { void putRequestingSize(@NonNull Object object, @NonNull Point size) { synchronized (requestingSizes) { requestingSizes.put(object, size); + dirtySizes = true; } } void removeRequestingSize(@NonNull Object object) { synchronized (requestingSizes) { requestingSizes.remove(object); + dirtySizes = true; } } @@ -71,6 +75,14 @@ public class BroadcastVideoSink implements VideoSink { return new RequestedSize(width, height); } + public void newSizeRequested() { + dirtySizes = false; + } + + public boolean needsNewRequestingSize() { + return dirtySizes; + } + public static class RequestedSize { private final int width; private final int height; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java index 213a9cac26..df838bf4f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.mms.GlideApp; @@ -42,6 +43,7 @@ public class CallParticipantView extends ConstraintLayout { private TextureViewRenderer renderer; private ImageView pipAvatar; private ContactPhoto contactPhoto; + private View audioMuted; public CallParticipantView(@NonNull Context context) { super(context); @@ -59,9 +61,10 @@ public class CallParticipantView extends ConstraintLayout { @Override protected void onFinishInflate() { super.onFinishInflate(); - avatar = findViewById(R.id.call_participant_item_avatar); - pipAvatar = findViewById(R.id.call_participant_item_pip_avatar); - renderer = findViewById(R.id.call_participant_renderer); + avatar = findViewById(R.id.call_participant_item_avatar); + pipAvatar = findViewById(R.id.call_participant_item_pip_avatar); + renderer = findViewById(R.id.call_participant_renderer); + audioMuted = findViewById(R.id.call_participant_mic_muted); avatar.setFallbackPhotoProvider(FALLBACK_PHOTO_PROVIDER); useLargeAvatar(); @@ -83,11 +86,13 @@ public class CallParticipantView extends ConstraintLayout { } if (participantChanged || !Objects.equals(contactPhoto, participant.getRecipient().getContactPhoto())) { - avatar.setAvatar(participant.getRecipient()); - AvatarUtil.loadBlurredIconIntoViewBackground(participant.getRecipient(), this); + avatar.setAvatarUsingProfile(participant.getRecipient()); + AvatarUtil.loadBlurredIconIntoViewBackground(participant.getRecipient(), this, true); setPipAvatar(participant.getRecipient()); contactPhoto = participant.getRecipient().getContactPhoto(); } + + audioMuted.setVisibility(participant.isMicrophoneEnabled() ? View.GONE : View.VISIBLE); } void setRenderInPip(boolean shouldRenderInPip) { @@ -103,6 +108,10 @@ public class CallParticipantView extends ConstraintLayout { changeAvatarParams(SMALL_AVATAR); } + void releaseRenderer() { + renderer.release(); + } + private void changeAvatarParams(int dimension) { ViewGroup.LayoutParams params = avatar.getLayoutParams(); if (params.height != dimension) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java index 2b1be7dd50..51da6ec9b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java @@ -14,6 +14,7 @@ import com.google.android.flexbox.FlexboxLayout; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; @@ -88,6 +89,8 @@ public class CallParticipantsLayout extends FlexboxLayout { } } else if (childCount > count) { for (int i = count; i < childCount; i++) { + CallParticipantView callParticipantView = getChildAt(count).findViewById(R.id.group_call_participant); + callParticipantView.releaseRenderer(); removeViewAt(count); } } 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 index bf1abd5c98..8aa4664b7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java @@ -5,9 +5,12 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +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.logging.Log; import org.thoughtcrime.securesms.ringrtc.CameraState; import java.util.ArrayList; @@ -146,6 +149,10 @@ public final class CallParticipantsState { return isInPipMode; } + public boolean needsNewRequestSizes() { + return Stream.of(getAllRemoteParticipants()).anyMatch(p -> p.getVideoSink().needsNewRequestingSize()); + } + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull WebRtcViewModel webRtcViewModel, boolean enableVideo) @@ -161,7 +168,7 @@ public final class CallParticipantsState { oldState.isInPipMode, newShowVideoForOutgoing, webRtcViewModel.getState(), - oldState.getAllRemoteParticipants().size(), + webRtcViewModel.getRemoteParticipants().size(), oldState.isViewingFocusedParticipant); CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); @@ -233,8 +240,10 @@ public final class CallParticipantsState { if (callState == WebRtcViewModel.State.CALL_CONNECTED) { if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) { localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE; - } else { + } else if (numberOfRemoteParticipants == 1) { localRenderState = WebRtcLocalRenderState.SMALL_RECTANGLE; + } else { + localRenderState = WebRtcLocalRenderState.LARGE; } } else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) { localRenderState = WebRtcLocalRenderState.LARGE; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java index f1dbe9cfb6..5f0c5678a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java @@ -89,6 +89,8 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf return; } + eglRenderer.clearImage(); + if (attachedVideoSink != null) { attachedVideoSink.removeSink(this); attachedVideoSink.removeRequestingSize(this); @@ -265,7 +267,9 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf @Override public void onFrame(VideoFrame videoFrame) { - eglRenderer.onFrame(videoFrame); + if (isAttachedToWindow()) { + eglRenderer.onFrame(videoFrame); + } } @Override 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 f6337a8383..b57156d462 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 @@ -92,9 +92,9 @@ public class WebRtcCallView extends FrameLayout { private RecyclerView callParticipantsRecycler; private Toolbar toolbar; private MaterialButton startCall; + private TextView participantCount; private int pagerBottomMarginDp; private boolean controlsVisible = true; - private EventListener eventListener; private WebRtcCallParticipantsPagerAdapter pagerAdapter; private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; @@ -168,7 +168,6 @@ public class WebRtcCallView extends FrameLayout { @Override public void onPageSelected(int position) { runIfNonNull(controlsListener, listener -> listener.onPageChanged(position == 0 ? CallParticipantsState.SelectedPage.GRID : CallParticipantsState.SelectedPage.FOCUSED)); - runIfNonNull(eventListener, EventListener::onPotentialLayoutChange); } }); @@ -239,10 +238,6 @@ public class WebRtcCallView extends FrameLayout { this.controlsListener = controlsListener; } - public void setEventListener(@Nullable EventListener eventListener) { - this.eventListener = eventListener; - } - public void setMicEnabled(boolean isMicEnabled) { micToggle.setChecked(isMicEnabled, false); } @@ -262,6 +257,12 @@ public class WebRtcCallView extends FrameLayout { recipientName.setText(state.getRemoteParticipantsDescription(getContext())); } + if (state.getGroupCallState().isNotIdle() && participantCount != null) { + boolean includeSelf = state.getGroupCallState() == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED; + + participantCount.setText(String.valueOf(state.getAllRemoteParticipants().size() + (includeSelf ? 1 : 0))); + } + pagerAdapter.submitList(pages); recyclerAdapter.submitList(state.getListParticipants()); updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant()); @@ -271,10 +272,6 @@ public class WebRtcCallView extends FrameLayout { } else { layoutParticipantsForSmallCount(); } - - if (eventListener != null) { - eventListener.onPotentialLayoutChange(); - } } public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, @NonNull CallParticipant localCallParticipant) { @@ -362,7 +359,11 @@ public class WebRtcCallView extends FrameLayout { recipientName.setText(getContext().getString(R.string.WebRtcCallView__s_group_call, recipient.getDisplayName(getContext()))); if (toolbar.getMenu().findItem(R.id.menu_group_call_participants_list) == null) { toolbar.inflateMenu(R.menu.group_call); - toolbar.setOnMenuItemClickListener(unused -> showParticipantsList()); + + View showParticipants = toolbar.getMenu().findItem(R.id.menu_group_call_participants_list).getActionView(); + showParticipants.setOnClickListener(unused -> showParticipantsList()); + + participantCount = showParticipants.findViewById(R.id.show_participants_menu_counter); } } else { recipientName.setText(recipient.getDisplayName(getContext())); @@ -398,15 +399,13 @@ public class WebRtcCallView extends FrameLayout { case DISCONNECTED: status.setText(R.string.WebRtcCallView__disconnected); break; - case CONNECTING: - status.setText(R.string.WebRtcCallView__connecting); - break; case RECONNECTING: status.setText(R.string.WebRtcCallView__reconnecting); break; case CONNECTED_AND_JOINING: status.setText(R.string.WebRtcCallView__joining); break; + case CONNECTING: case CONNECTED_AND_JOINED: case CONNECTED: status.setText(""); 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 7fb99d6146..eb71997119 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 @@ -117,7 +117,7 @@ public class WebRtcCallViewModel extends ViewModel { if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) { callConnectedTime = webRtcViewModel.getCallConnectedTime(); startTimer(); - } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED) { + } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) { cancelTimer(); callConnectedTime = -1; } 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 bfa12f2d07..88d03bd69e 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 @@ -57,7 +57,7 @@ public final class WebRtcControls { } boolean displayGroupMembersButton() { - return groupCallState == GroupCallState.CONNECTED; + return groupCallState.isAtLeast(GroupCallState.CONNECTING); } boolean displayEndCall() { @@ -156,8 +156,12 @@ public final class WebRtcControls { public enum GroupCallState { NONE, DISCONNECTED, + RECONNECTING, CONNECTING, - CONNECTED, - RECONNECTING + CONNECTED; + + boolean isAtLeast(@SuppressWarnings("SameParameterValue") @NonNull GroupCallState other) { + return compareTo(other) >= 0; + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java index bb792fb576..5d37f4422a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java @@ -1,18 +1,30 @@ package org.thoughtcrime.securesms.components.webrtc.participantslist; import android.view.View; +import android.widget.ImageView; import androidx.annotation.NonNull; +import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; public class CallParticipantViewHolder extends RecipientViewHolder { + + private final ImageView videoMuted; + private final ImageView audioMuted; + public CallParticipantViewHolder(@NonNull View itemView) { super(itemView, null); + + videoMuted = itemView.findViewById(R.id.call_participant_video_muted); + audioMuted = itemView.findViewById(R.id.call_participant_audio_muted); } @Override public void bind(@NonNull CallParticipantViewState model) { super.bind(model); + + videoMuted.setVisibility(model.getVideoMutedVisibility()); + audioMuted.setVisibility(model.getAudioMutedVisibility()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java index 579c31b91d..8cdced6b9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java @@ -1,7 +1,11 @@ package org.thoughtcrime.securesms.components.webrtc.participantslist; +import android.content.Context; +import android.view.View; + import androidx.annotation.NonNull; +import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; @@ -18,4 +22,18 @@ public final class CallParticipantViewState extends RecipientMappingModel pendingRecipients) { GroupsV1MigrationInfoBottomSheetDialogFragment.showForLearnMore(requireFragmentManager(), pendingRecipients); } + + @Override + public void onJoinGroupCallClicked() { + CommunicationActions.startVideoCall(requireActivity(), recipient.get()); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 3ed9b79afe..b1494babdb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -27,13 +27,17 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.Collection; import java.util.Locale; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ExecutionException; public final class ConversationUpdateItem extends LinearLayout @@ -177,6 +181,28 @@ public final class ConversationUpdateItem extends LinearLayout eventListener.onGroupMigrationLearnMoreClicked(conversationMessage.getMessageRecord().getGroupV1MigrationEventInvites()); } }); + } else if (conversationMessage.getMessageRecord().isGroupCall()) { + + UpdateDescription updateDescription = MessageRecord.getGroupCallUpdateDescription(getContext(), conversationMessage.getMessageRecord().getBody()); + Collection uuids = updateDescription.getMentioned(); + + int text = 0; + if (Util.hasItems(uuids)) { + text = uuids.contains(TextSecurePreferences.getLocalUuid(getContext())) ? R.string.ConversationUpdateItem_return_to_call : R.string.ConversationUpdateItem_join_call; + } + + if (text != 0) { + actionButton.setText(text); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onJoinGroupCallClicked(); + } + }); + } else { + actionButton.setVisibility(GONE); + actionButton.setOnClickListener(null); + } } else { actionButton.setVisibility(GONE); actionButton.setOnClickListener(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index eeb369cb99..15b9a95bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -461,6 +461,8 @@ public final class ConversationListItem extends RelativeLayout return emphasisAdded(context, context.getString(R.string.ThreadRecord_missed_audio_call), defaultTint); } else if (SmsDatabase.Types.isMissedVideoCall(thread.getType())) { return emphasisAdded(context, context.getString(R.string.ThreadRecord_missed_video_call), defaultTint); + } else if (MmsSmsColumns.Types.isGroupCall(thread.getType())) { + return emphasisAdded(context, MessageRecord.getGroupCallUpdateDescription(context, thread.getBody()), defaultTint); } else if (SmsDatabase.Types.isJoinedType(thread.getType())) { return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> new SpannableString(context.getString(R.string.ThreadRecord_s_is_on_signal, r.getDisplayName(context))))); } else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 09fc6daf87..0c3e73b691 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -23,7 +23,6 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; -import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList; import org.thoughtcrime.securesms.insights.InsightsConstants; import org.thoughtcrime.securesms.logging.Log; @@ -50,6 +49,7 @@ import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.UUID; public abstract class MessageDatabase extends Database implements MmsSmsColumns { @@ -130,6 +130,12 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract @NonNull Pair insertReceivedCall(@NonNull RecipientId address, boolean isVideoOffer); public abstract @NonNull Pair insertOutgoingCall(@NonNull RecipientId address, boolean isVideoOffer); public abstract @NonNull Pair insertMissedCall(@NonNull RecipientId address, long timestamp, boolean isVideoOffer); + public abstract @NonNull void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String messageGroupCallEraId, + @Nullable String peekGroupCallEraId, + @NonNull Collection peekJoinedUuids); public abstract Optional insertMessageInbox(IncomingTextMessage message, long type); public abstract Optional insertMessageInbox(IncomingTextMessage message); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 2da95b396a..4caaa2a764 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -92,6 +92,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; @@ -397,6 +398,17 @@ public class MmsDatabase extends MessageDatabase { throw new UnsupportedOperationException(); } + @Override + public @NonNull void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String messageGroupCallEraId, + @Nullable String peekGroupCallEraId, + @NonNull Collection peekJoinedUuids) + { + throw new UnsupportedOperationException(); + } + @Override public Optional insertMessageInbox(IncomingTextMessage message, long type) { throw new UnsupportedOperationException(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index ada28e68b0..1d788f99d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -44,6 +44,7 @@ public interface MmsSmsColumns { protected static final long GV1_MIGRATION_TYPE = 9; protected static final long INCOMING_VIDEO_CALL_TYPE = 10; protected static final long OUTGOING_VIDEO_CALL_TYPE = 11; + protected static final long GROUP_CALL_TYPE = 12; protected static final long BASE_INBOX_TYPE = 20; protected static final long BASE_OUTBOX_TYPE = 21; @@ -214,7 +215,8 @@ public interface MmsSmsColumns { isOutgoingAudioCall(type) || isOutgoingVideoCall(type) || isMissedAudioCall(type) || - isMissedVideoCall(type); + isMissedVideoCall(type) || + isGroupCall(type); } public static boolean isExpirationTimerUpdate(long type) { @@ -237,7 +239,6 @@ public interface MmsSmsColumns { return type == OUTGOING_VIDEO_CALL_TYPE; } - public static boolean isMissedAudioCall(long type) { return type == MISSED_AUDIO_CALL_TYPE; } @@ -246,6 +247,10 @@ public interface MmsSmsColumns { return type == MISSED_VIDEO_CALL_TYPE; } + public static boolean isGroupCall(long type) { + return type == GROUP_CALL_TYPE; + } + public static boolean isGroupUpdate(long type) { return (type & GROUP_UPDATE_BIT) != 0; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 5543f5b8d1..e208528fc6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -35,9 +35,11 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList; import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.TrimThreadJob; @@ -57,6 +59,7 @@ import org.thoughtcrime.securesms.util.CursorUtil; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; @@ -69,7 +72,9 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.UUID; /** * Database for storage of SMS messages. @@ -666,6 +671,89 @@ public class SmsDatabase extends MessageDatabase { return insertCallLog(address, isVideoOffer ? Types.MISSED_VIDEO_CALL_TYPE : Types.MISSED_AUDIO_CALL_TYPE, true, timestamp); } + @Override + public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, + @NonNull RecipientId sender, + long timestamp, + @Nullable String messageGroupCallEraId, + @Nullable String peekGroupCallEraId, + @NonNull Collection peekJoinedUuids) + { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + try { + db.beginTransaction(); + + Recipient recipient = Recipient.resolved(groupRecipientId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + boolean peerEraIdSameAsPrevious = updatePreviousGroupCall(threadId, peekGroupCallEraId, peekJoinedUuids); + + if (!peerEraIdSameAsPrevious && !Util.isEmpty(peekGroupCallEraId)) { + byte[] updateDetails = GroupCallUpdateDetails.newBuilder() + .setEraId(Util.emptyIfNull(peekGroupCallEraId)) + .setStartedCallUuid(Recipient.resolved(sender).requireUuid().toString()) + .setStartedCallTimestamp(timestamp) + .addAllInCallUuids(Stream.of(peekJoinedUuids).map(UUID::toString).toList()) + .build() + .toByteArray(); + + String body = Base64.encodeBytes(updateDetails); + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, sender.serialize()); + values.put(ADDRESS_DEVICE_ID, 1); + values.put(DATE_RECEIVED, timestamp); + values.put(DATE_SENT, timestamp); + values.put(READ, 0); + values.put(BODY, body); + values.put(TYPE, Types.GROUP_CALL_TYPE); + values.put(THREAD_ID, threadId); + + db.insert(TABLE_NAME, null, values); + + DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); + } + + DatabaseFactory.getThreadDatabase(context).update(threadId, true); + + notifyConversationListeners(threadId); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private boolean updatePreviousGroupCall(long threadId, @Nullable String peekGroupCallEraId, @NonNull Collection peekJoinedUuids) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = TYPE + " = ? AND " + THREAD_ID + " = ?"; + String[] args = SqlUtil.buildArgs(Types.GROUP_CALL_TYPE, threadId); + + try (Reader reader = new Reader(db.query(TABLE_NAME, MESSAGE_PROJECTION, where, args, null, null, DATE_RECEIVED + " DESC", "1"))) { + MessageRecord record = reader.getNext(); + if (record == null) { + return false; + } + + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.getBody()); + boolean sameEraId = groupCallUpdateDetails.getEraId().equals(peekGroupCallEraId) && !Util.isEmpty(peekGroupCallEraId); + + List inCallUuids = sameEraId ? Stream.of(peekJoinedUuids).map(UUID::toString).toList() + : Collections.emptyList(); + + String body = GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, inCallUuids); + + ContentValues contentValues = new ContentValues(); + contentValues.put(BODY, body); + + db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(record.getId())); + + return sameEraId; + } + } + private @NonNull Pair insertCallLog(@NonNull RecipientId recipientId, long type, boolean unread, long timestamp) { Recipient recipient = Recipient.resolved(recipientId); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index cbb19e6dfc..43f66a5aeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -164,6 +164,10 @@ public abstract class DisplayRecord { return SmsDatabase.Types.isMissedVideoCall(type); } + public final boolean isGroupCall() { + return SmsDatabase.Types.isGroupCall(type); + } + public boolean isVerificationStatusChange() { return SmsDatabase.Types.isIdentityDefault(type) || SmsDatabase.Types.isIdentityVerified(type); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java new file mode 100644 index 0000000000..908f6e3611 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.List; + +public final class GroupCallUpdateDetailsUtil { + + private static final String TAG = Log.tag(GroupCallUpdateDetailsUtil.class); + + private GroupCallUpdateDetailsUtil() { + } + + public static @NonNull GroupCallUpdateDetails parse(@Nullable String body) { + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetails.getDefaultInstance(); + + if (body == null) { + return groupCallUpdateDetails; + } + + try { + groupCallUpdateDetails = GroupCallUpdateDetails.parseFrom(Base64.decode(body)); + } catch (IOException e) { + Log.w(TAG, "Group call update details could not be read", e); + } + + return groupCallUpdateDetails; + } + + public static @NonNull String createUpdatedBody(@NonNull GroupCallUpdateDetails groupCallUpdateDetails, @NonNull List inCallUuids) { + GroupCallUpdateDetails.Builder builder = groupCallUpdateDetails.toBuilder() + .clearInCallUuids(); + + if (Util.hasItems(inCallUuids)) { + builder.addAllInCallUuids(inCallUuids); + } + + return Base64.encodeBytes(builder.build().toByteArray()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java new file mode 100644 index 0000000000..a3844967e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.database.model; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +/** + * Create a group call update message based on time and joined members. + */ +public class GroupCallUpdateMessageFactory implements UpdateDescription.StringFactory { + private final Context context; + private final List joinedMembers; + private final GroupCallUpdateDetails groupCallUpdateDetails; + private final UUID selfUuid; + + public GroupCallUpdateMessageFactory(@NonNull Context context, @NonNull List joinedMembers, @NonNull GroupCallUpdateDetails groupCallUpdateDetails) { + this.context = context; + this.joinedMembers = new ArrayList<>(joinedMembers); + this.groupCallUpdateDetails = groupCallUpdateDetails; + this.selfUuid = TextSecurePreferences.getLocalUuid(context); + + boolean removed = this.joinedMembers.remove(selfUuid); + if (removed) { + this.joinedMembers.add(selfUuid); + } + } + + @Override + public @NonNull String create() { + String time = DateUtils.getTimeString(context, Locale.getDefault(), groupCallUpdateDetails.getStartedCallTimestamp()); + + switch (joinedMembers.size()) { + case 0: + return context.getString(R.string.MessageRecord_group_call_s, time); + case 1: + if (joinedMembers.get(0).toString().equals(groupCallUpdateDetails.getStartedCallUuid())) { + return context.getString(R.string.MessageRecord_s_started_a_group_call_s, describe(joinedMembers.get(0)), time); + } else if (Objects.equals(joinedMembers.get(0), selfUuid)) { + return context.getString(R.string.MessageRecord_you_are_in_the_group_call_s, describe(joinedMembers.get(0)), time); + } else { + return context.getString(R.string.MessageRecord_s_is_in_the_group_call_s, describe(joinedMembers.get(0)), time); + } + case 2: + return context.getString(R.string.MessageRecord_s_and_s_are_in_the_group_call_s, + describe(joinedMembers.get(0)), + describe(joinedMembers.get(1)), + time); + default: + int others = joinedMembers.size() - 2; + return context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_group_call_s, + others, + describe(joinedMembers.get(0)), + describe(joinedMembers.get(1)), + others, + time); + } + } + + private @NonNull String describe(@NonNull UUID uuid) { + if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { + return context.getString(R.string.MessageRecord_unknown); + } + + Recipient recipient = Recipient.resolved(RecipientId.from(uuid, null)); + + if (recipient.isSelf()) { + return context.getString(R.string.MessageRecord_you); + } else { + return recipient.getShortDisplayName(context); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index c95771bcbf..c2dcba9dc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -28,6 +28,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import com.annimon.stream.Stream; + import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.MmsSmsColumns; @@ -35,6 +37,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.profiles.ProfileName; @@ -154,6 +157,8 @@ public abstract class MessageRecord extends DisplayRecord { return staticUpdateDescription(context.getString(R.string.MessageRecord_missed_audio_call_date, getCallDateString(context)), R.drawable.ic_update_audio_call_missed_16, ContextCompat.getColor(context, R.color.core_red_shade), ContextCompat.getColor(context, R.color.core_red)); } else if (isMissedVideoCall()) { return staticUpdateDescription(context.getString(R.string.MessageRecord_missed_video_call_date, getCallDateString(context)), R.drawable.ic_update_video_call_missed_16, ContextCompat.getColor(context, R.color.core_red_shade), ContextCompat.getColor(context, R.color.core_red)); + } else if (isGroupCall()) { + return getGroupCallUpdateDescription(context, getBody()); } else if (isJoined()) { return staticUpdateDescription(context.getString(R.string.MessageRecord_s_joined_signal, getIndividualRecipient().getDisplayName(context)), R.drawable.ic_update_group_add_16); } else if (isExpirationTimerUpdate()) { @@ -281,6 +286,19 @@ public abstract class MessageRecord extends DisplayRecord { return context.getString(R.string.MessageRecord_changed_their_profile, getIndividualRecipient().getDisplayName(context)); } + public static @NonNull UpdateDescription getGroupCallUpdateDescription(@NonNull Context context, @NonNull String body) { + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(body); + + List joinedMembers = Stream.of(groupCallUpdateDetails.getInCallUuidsList()) + .map(UuidUtil::parseOrNull) + .withoutNulls() + .toList(); + + UpdateDescription.StringFactory stringFactory = new GroupCallUpdateMessageFactory(context, joinedMembers, groupCallUpdateDetails); + + return UpdateDescription.mentioning(joinedMembers, stringFactory, R.drawable.ic_video_16); + } + /** * Describes a UUID by it's corresponding recipient's {@link Recipient#getDisplayName(Context)}. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 899e9b9ce6..b600dc1581 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.jobmanager.JobMigrator; import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate; import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; import org.thoughtcrime.securesms.jobs.FastJobStorage; +import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob; import org.thoughtcrime.securesms.jobs.JobManagerFactories; import org.thoughtcrime.securesms.jobs.MarkerJob; import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob; @@ -146,7 +147,7 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr .setJobStorage(new FastJobStorage(DatabaseFactory.getJobDatabase(context), SignalExecutors.newCachedSingleThreadExecutor("signal-fast-job-storage"))) .setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context))) .addReservedJobRunner(new FactoryJobPredicate(PushDecryptMessageJob.KEY, PushProcessMessageJob.KEY, MarkerJob.KEY)) - .addReservedJobRunner(new FactoryJobPredicate(PushTextSendJob.KEY, PushMediaSendJob.KEY, PushGroupSendJob.KEY, ReactionSendJob.KEY, TypingSendJob.KEY)) + .addReservedJobRunner(new FactoryJobPredicate(PushTextSendJob.KEY, PushMediaSendJob.KEY, PushGroupSendJob.KEY, ReactionSendJob.KEY, TypingSendJob.KEY, GroupCallUpdateSendJob.KEY)) .build()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java index 787b4f3d8c..db7315a6b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java @@ -12,7 +12,7 @@ import java.util.Objects; public class CallParticipant { - public static final CallParticipant EMPTY = createRemote(Recipient.UNKNOWN, null, new BroadcastVideoSink(null), false); + public static final CallParticipant EMPTY = createRemote(Recipient.UNKNOWN, null, new BroadcastVideoSink(null), false, false); private final @NonNull CameraState cameraState; private final @NonNull Recipient recipient; @@ -36,9 +36,10 @@ public class CallParticipant { public static @NonNull CallParticipant createRemote(@NonNull Recipient recipient, @Nullable IdentityKey identityKey, @NonNull BroadcastVideoSink renderer, + boolean audioEnabled, boolean videoEnabled) { - return new CallParticipant(recipient, identityKey, renderer, CameraState.UNKNOWN, videoEnabled, true); + return new CallParticipant(recipient, identityKey, renderer, CameraState.UNKNOWN, videoEnabled, audioEnabled); } private CallParticipant(@NonNull Recipient recipient, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java new file mode 100644 index 0000000000..cf775668b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +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.push.SignalServiceAddress; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Send a group call update message to every one in a V2 group. Used to indicate you + * have joined or left a call. + */ +public class GroupCallUpdateSendJob extends BaseJob { + + public static final String KEY = "GroupCallUpdateSendJob"; + + private static final String TAG = Log.tag(GroupCallUpdateSendJob.class); + + private static final String KEY_RECIPIENT_ID = "recipient_id"; + private static final String KEY_ERA_ID = "era_id"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + + private final RecipientId recipientId; + private final String eraId; + private final List recipients; + private final int initialRecipientCount; + + @WorkerThread + public static @NonNull GroupCallUpdateSendJob create(@NonNull RecipientId recipientId, @Nullable String eraId) { + Recipient conversationRecipient = Recipient.resolved(recipientId); + + if (!conversationRecipient.isPushV2Group()) { + throw new AssertionError("We have a recipient, but it's not a V2 Group"); + } + + List recipients = Stream.of(RecipientUtil.getEligibleForSending(conversationRecipient.getParticipants())) + .filterNot(Recipient::isSelf) + .map(Recipient::getId) + .toList(); + + return new GroupCallUpdateSendJob(recipientId, + eraId, + recipients, + recipients.size(), + new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.MINUTES.toMillis(5)) + .setMaxAttempts(3) + .build()); + } + + private GroupCallUpdateSendJob(@NonNull RecipientId recipientId, + @NonNull String eraId, + @NonNull List recipients, + int initialRecipientCount, + @NonNull Parameters parameters) + { + super(parameters); + + this.recipientId = recipientId; + this.eraId = eraId; + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize()) + .putString(KEY_ERA_ID, eraId) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + Recipient conversationRecipient = Recipient.resolved(recipientId); + + if (!conversationRecipient.isPushV2Group()) { + throw new AssertionError("We have a recipient, but it's not a V2 Group"); + } + + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(conversationRecipient, destinations); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + if (recipients.size() < initialRecipientCount) { + Log.w(TAG, "Only sent a group update to " + recipients.size() + "/" + initialRecipientCount + " recipients. Still, it sent to someone, so it stays."); + return; + } + + Log.w(TAG, "Failed to send the group update to all recipients!"); + } + + private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations);; + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withGroupCallUpdate(new SignalServiceDataMessage.GroupCallUpdate(eraId)); + + if (conversationRecipient.isGroup()) { + GroupUtil.setDataMessageGroupContext(context, dataMessage, conversationRecipient.requireGroupId().requirePush()); + } + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + return GroupSendJobHelper.getCompletedSends(context, results); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull + GroupCallUpdateSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + RecipientId recipientId = RecipientId.from(data.getString(KEY_RECIPIENT_ID)); + String eraId = data.getString(KEY_ERA_ID); + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + + return new GroupCallUpdateSendJob(recipientId, eraId, recipients, initialRecipientCount, parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index bd3c49ca7d..113bedd0d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -70,6 +70,7 @@ public final class JobManagerFactories { put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory()); put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory()); put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory()); + put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory()); put(KbsEnclaveMigrationWorkerJob.KEY, new KbsEnclaveMigrationWorkerJob.Factory()); put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index ef64e15d65..6b0e4d9a90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -84,6 +84,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.tracing.Trace; import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Hex; import org.thoughtcrime.securesms.util.IdentityUtil; @@ -373,14 +374,15 @@ public final class PushProcessMessageJob extends BaseJob { } } - if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), groupId, content.getTimestamp(), smsMessageId); - else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); - else if (message.isGroupV1Update()) handleGroupV1Message(content, message, smsMessageId, groupId.get().requireV1()); - else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId, groupId); - else if (message.getReaction().isPresent()) handleReaction(content, message); - else if (message.getRemoteDelete().isPresent()) handleRemoteDelete(content, message); - else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); - else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, groupId); + if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), groupId, content.getTimestamp(), smsMessageId); + else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); + else if (message.isGroupV1Update()) handleGroupV1Message(content, message, smsMessageId, groupId.get().requireV1()); + else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId, groupId); + else if (message.getReaction().isPresent()) handleReaction(content, message); + else if (message.getRemoteDelete().isPresent()) handleRemoteDelete(content, message); + else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); + else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, groupId); + else if (FeatureFlags.groupCalling() && message.getGroupCallUpdate().isPresent()) handleGroupCallUpdateMessage(content, message, groupId); if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { handleUnknownGroupMessage(content, message.getGroupContext().get()); @@ -649,6 +651,28 @@ public final class PushProcessMessageJob extends BaseJob { context.startService(intent); } + private void handleGroupCallUpdateMessage(@NonNull SignalServiceContent content, + @NonNull SignalServiceDataMessage message, + @NonNull Optional groupId) + { + if (!groupId.isPresent() || !groupId.get().isV2()) { + Log.w(TAG, "Invalid group for group call update message"); + return; + } + + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(groupId.get()); + + Intent intent = new Intent(context, WebRtcCallService.class); + + intent.setAction(WebRtcCallService.ACTION_GROUP_CALL_UPDATE_MESSAGE) + .putExtra(WebRtcCallService.EXTRA_GROUP_CALL_UPDATE_SENDER, RecipientId.from(content.getSender()).serialize()) + .putExtra(WebRtcCallService.EXTRA_GROUP_CALL_UPDATE_GROUP, groupRecipientId.serialize()) + .putExtra(WebRtcCallService.EXTRA_GROUP_CALL_ERA_ID, message.getGroupCallUpdate().get().getEraId()) + .putExtra(WebRtcCallService.EXTRA_SERVER_RECEIVED_TIMESTAMP, content.getServerReceivedTimestamp()); + + context.startService(intent); + } + private void handleEndSessionMessage(@NonNull SignalServiceContent content, @NonNull Optional smsMessageId) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java index 2ae968a783..78ea6e1cb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java @@ -29,13 +29,16 @@ import org.signal.ringrtc.Remote; import org.signal.storageservice.protos.groups.GroupExternalCredential; import org.signal.zkgroup.VerificationFailedException; import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -47,12 +50,15 @@ import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.ringrtc.TurnServerInfoParcel; import org.thoughtcrime.securesms.service.webrtc.IdleActionProcessor; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData; import org.thoughtcrime.securesms.service.webrtc.WebRtcInteractor; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.util.FutureTaskListener; import org.thoughtcrime.securesms.util.ListenableFutureTask; import org.thoughtcrime.securesms.util.TelephonyUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager; import org.thoughtcrime.securesms.webrtc.audio.BluetoothStateManager; @@ -125,6 +131,11 @@ public class WebRtcCallService extends Service implements CallManager.Observer, public static final String EXTRA_OPAQUE_MESSAGE = "opaque"; public static final String EXTRA_UUID = "uuid"; public static final String EXTRA_MESSAGE_AGE_SECONDS = "message_age_seconds"; + public static final String EXTRA_GROUP_CALL_END_REASON = "group_call_end_reason"; + public static final String EXTRA_GROUP_CALL_HASH = "group_call_hash"; + public static final String EXTRA_GROUP_CALL_UPDATE_SENDER = "group_call_update_sender"; + public static final String EXTRA_GROUP_CALL_UPDATE_GROUP = "group_call_update_group"; + public static final String EXTRA_GROUP_CALL_ERA_ID = "era_id"; public static final String ACTION_PRE_JOIN_CALL = "CALL_PRE_JOIN"; public static final String ACTION_CANCEL_PRE_JOIN_CALL = "CANCEL_PRE_JOIN_CALL"; @@ -187,6 +198,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, public static final String ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF = "GROUP_REQUEST_MEMBERSHIP_PROOF"; public static final String ACTION_GROUP_REQUEST_UPDATE_MEMBERS = "GROUP_REQUEST_UPDATE_MEMBERS"; public static final String ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS = "GROUP_UPDATE_RENDERED_RESOLUTIONS"; + public static final String ACTION_GROUP_CALL_ENDED = "GROUP_CALL_ENDED"; + public static final String ACTION_GROUP_CALL_UPDATE_MESSAGE = "GROUP_CALL_UPDATE_MESSAGE"; public static final int BUSY_TONE_LENGTH = 2000; @@ -652,6 +665,38 @@ public class WebRtcCallService extends Service implements CallManager.Observer, sendMessage(new RemotePeer(RecipientId.from(uuid, null)), opaqueMessage); } + public void sendGroupCallMessage(@NonNull Recipient recipient, @Nullable String groupCallEraId) { + SignalExecutors.BOUNDED.execute(() -> ApplicationDependencies.getJobManager().add(GroupCallUpdateSendJob.create(recipient.getId(), groupCallEraId))); + + peekGroupCall(new WebRtcData.GroupCallUpdateMetadata(Recipient.self().getId(), recipient.getId(), groupCallEraId, System.currentTimeMillis())); + } + + public void peekGroupCall(@NonNull WebRtcData.GroupCallUpdateMetadata groupCallUpdateMetadata) { + networkExecutor.execute(() -> { + try { + Recipient group = Recipient.resolved(groupCallUpdateMetadata.getGroupRecipientId()); + GroupId.V2 groupId = group.requireGroupId().requireV2(); + GroupExternalCredential credential = GroupManager.getGroupExternalCredential(this, groupId); + + List members = Stream.of(GroupManager.getUuidCipherTexts(this, groupId)) + .map(entry -> new GroupCall.GroupMemberInfo(entry.getKey(), entry.getValue().serialize())) + .toList(); + + callManager.peekGroupCall(BuildConfig.SIGNAL_SFU_URL, credential.getTokenBytes().toByteArray(), members, peekInfo -> { + DatabaseFactory.getSmsDatabase(this).insertOrUpdateGroupCall(group.getId(), + groupCallUpdateMetadata.getSender(), + groupCallUpdateMetadata.getServerReceivedTimestamp(), + groupCallUpdateMetadata.getGroupCallEraId(), + peekInfo.getEraId(), + peekInfo.getJoinedMembers()); + }); + + } catch (IOException | VerificationFailedException | CallException e) { + Log.e(TAG, "error peeking", e); + } + }); + } + @Override public void onStartCall(@Nullable Remote remote, @NonNull CallId callId, @NonNull Boolean isOutgoing, @Nullable CallManager.CallMediaType callMediaType) { Log.i(TAG, "onStartCall(): callId: " + callId + ", outgoing: " + isOutgoing + ", type: " + callMediaType); @@ -967,11 +1012,13 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Intent intent = new Intent(this, WebRtcCallService.class); intent.setAction(ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF) - .putExtra(EXTRA_GROUP_EXTERNAL_TOKEN, credential.getTokenBytes().toByteArray()); + .putExtra(EXTRA_GROUP_EXTERNAL_TOKEN, credential.getTokenBytes().toByteArray()) + .putExtra(EXTRA_GROUP_CALL_HASH, groupCall.hashCode()); startService(intent); } catch (IOException | VerificationFailedException e) { Log.w(TAG, "Unable to fetch group membership proof", e); + onEnded(groupCall, GroupCall.GroupCallEndReason.DEVICE_EXPLICITLY_DISCONNECTED); } }); } @@ -992,12 +1039,18 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } @Override - public void onJoinedMembersChanged(@NonNull GroupCall groupCall) { + public void onPeekChanged(@NonNull GroupCall groupCall) { startService(new Intent(this, WebRtcCallService.class).setAction(ACTION_GROUP_JOINED_MEMBERSHIP_CHANGED)); } @Override public void onEnded(@NonNull GroupCall groupCall, @NonNull GroupCall.GroupCallEndReason groupCallEndReason) { - Log.i(TAG, "onEnded: " + groupCallEndReason); + Intent intent = new Intent(this, WebRtcCallService.class); + + intent.setAction(ACTION_GROUP_CALL_ENDED) + .putExtra(EXTRA_GROUP_CALL_HASH, groupCall.hashCode()) + .putExtra(EXTRA_GROUP_CALL_END_REASON, groupCallEndReason.ordinal()); + + startService(intent); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java index c87730fc5f..17c4a25da6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java @@ -44,6 +44,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor { remotePeer.getRecipient(), null, new BroadcastVideoSink(currentState.getVideoState().getEglBase()), + true, false )) .build(); @@ -82,6 +83,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor { remotePeer.getRecipient(), null, new BroadcastVideoSink(currentState.getVideoState().getEglBase()), + true, false )) .build(); 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 900718bb59..6f903ad239 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 @@ -48,14 +48,14 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { LongSparseArray remoteDevices = groupCall.getRemoteDeviceStates(); - for(int i = 0; i < remoteDevices.size(); i++) { + for (int i = 0; i < remoteDevices.size(); i++) { GroupCall.RemoteDeviceState device = remoteDevices.get(remoteDevices.keyAt(i)); Recipient recipient = Recipient.externalPush(context, device.getUserId(), null, false); CallParticipantId callParticipantId = new CallParticipantId(device.getDemuxId(), recipient.getId()); CallParticipant callParticipant = participants.get(callParticipantId); BroadcastVideoSink videoSink; - VideoTrack videoTrack = device.getVideoTrack(); + VideoTrack videoTrack = device.getVideoTrack(); if (videoTrack != null) { videoSink = (callParticipant != null && callParticipant.getVideoSink().getEglBase() != null) ? callParticipant.getVideoSink() : new BroadcastVideoSink(currentState.getVideoState().requireEglBase()); @@ -68,17 +68,23 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { CallParticipant.createRemote(recipient, null, videoSink, - true)); + Boolean.FALSE.equals(device.getAudioMuted()), + Boolean.FALSE.equals(device.getVideoMuted()))); } return builder.build(); } @Override - protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, @NonNull byte[] groupMembershipToken) { + protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull byte[] groupMembershipToken) { Log.i(tag, "handleGroupRequestMembershipProof():"); - GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); + + if (groupCall == null || groupCall.hashCode() != groupCallHash) { + return currentState; + } + try { groupCall.setMembershipProof(groupMembershipToken); } catch (CallException e) { @@ -96,7 +102,7 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); List members = Stream.of(GroupManager.getUuidCipherTexts(context, group.requireGroupId().requireV2())) - .map(e -> new GroupCall.GroupMemberInfo(e.getKey(), e.getValue().serialize())) + .map(entry -> new GroupCall.GroupMemberInfo(entry.getKey(), entry.getValue().serialize())) .toList(); try { @@ -109,17 +115,20 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { } @Override - protected @NonNull WebRtcServiceState handleUpdateRenderedResolutions(@NonNull WebRtcServiceState currentState) { + protected @NonNull WebRtcServiceState handleUpdateRenderedResolutions(@NonNull WebRtcServiceState currentState) { Map participants = currentState.getCallInfoState().getRemoteCallParticipantsMap(); - ArrayList renderedResolutions = new ArrayList<>(participants.size()); + ArrayList resolutionRequests = new ArrayList<>(participants.size()); for (Map.Entry entry : participants.entrySet()) { - BroadcastVideoSink.RequestedSize maxSize = entry.getValue().getVideoSink().getMaxRequestingSize(); - renderedResolutions.add(new GroupCall.RenderedResolution(entry.getKey().getDemuxId(), maxSize.getWidth(), maxSize.getHeight(), null)); + BroadcastVideoSink videoSink = entry.getValue().getVideoSink(); + BroadcastVideoSink.RequestedSize maxSize = videoSink.getMaxRequestingSize(); + + resolutionRequests.add(new GroupCall.VideoRequest(entry.getKey().getDemuxId(), maxSize.getWidth(), maxSize.getHeight(), null)); + videoSink.newSizeRequested(); } try { - currentState.getCallInfoState().requireGroupCall().setRenderedResolutions(renderedResolutions); + currentState.getCallInfoState().requireGroupCall().requestVideo(resolutionRequests); } catch (CallException e) { return groupCallFailure(currentState, "Unable to set rendered resolutions", e); } @@ -127,7 +136,7 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { return currentState; } - protected @NonNull WebRtcServiceState handleHttpSuccess(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.HttpData httpData) { + protected @NonNull WebRtcServiceState handleHttpSuccess(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.HttpData httpData) { try { webRtcInteractor.getCallManager().receivedHttpResponse(httpData.getRequestId(), httpData.getStatus(), httpData.getBody() != null ? httpData.getBody() : new byte[0]); } catch (CallException e) { @@ -136,7 +145,7 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { return currentState; } - protected @NonNull WebRtcServiceState handleHttpFailure(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.HttpData httpData) { + protected @NonNull WebRtcServiceState handleHttpFailure(@NonNull WebRtcServiceState currentState, @NonNull WebRtcData.HttpData httpData) { try { webRtcInteractor.getCallManager().httpRequestFailed(httpData.getRequestId()); } catch (CallException e) { @@ -174,9 +183,43 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { return currentState; } + @Override + protected @NonNull WebRtcServiceState handleGroupCallEnded(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull GroupCall.GroupCallEndReason groupCallEndReason) { + Log.i(tag, "handleGroupCallEnded(): reason: " + groupCallEndReason); + + GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); + + if (groupCall == null || groupCall.hashCode() != groupCallHash) { + return currentState; + } + + try { + groupCall.disconnect(); + } catch (CallException e) { + return groupCallFailure(currentState, "Unable to disconnect from group call", e); + } + + currentState = currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_DISCONNECTED) + .groupCallState(WebRtcViewModel.GroupCallState.DISCONNECTED) + .build(); + + webRtcInteractor.sendMessage(currentState); + + 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) @@ -186,7 +229,6 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor { webRtcInteractor.sendMessage(currentState); try { - GroupCall groupCall = currentState.getCallInfoState().getGroupCall(); if (groupCall != null) { groupCall.disconnect(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java index 5a153e0264..173bf8a704 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import org.signal.ringrtc.CallException; import org.signal.ringrtc.GroupCall; +import org.signal.ringrtc.PeekInfo; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.ringrtc.Camera; @@ -20,6 +21,32 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { super(webRtcInteractor, TAG); } + @Override + protected @NonNull WebRtcServiceState handleGroupLocalDeviceStateChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupLocalDeviceStateChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + GroupCall.LocalDeviceState device = groupCall.getLocalDeviceState(); + GroupCall.ConnectionState connectionState = device.getConnectionState(); + GroupCall.JoinState joinState = device.getJoinState(); + + Log.i(tag, "local device changed: " + connectionState + " " + joinState); + + WebRtcViewModel.GroupCallState groupCallState = WebRtcUtil.groupCallStateForConnection(connectionState); + + if (connectionState == GroupCall.ConnectionState.CONNECTED || connectionState == GroupCall.ConnectionState.CONNECTING) { + if (joinState == GroupCall.JoinState.JOINED) { + groupCallState = WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED; + } else if (joinState == GroupCall.JoinState.JOINING) { + groupCallState = WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING; + } + } + + return currentState.builder().changeCallInfoState() + .groupCallState(groupCallState) + .build(); + } + @Override protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) { Log.i(TAG, "handleSetEnableVideo():"); @@ -58,16 +85,43 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { .build(); } + @Override + protected @NonNull WebRtcServiceState handleGroupJoinedMembershipChanged(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleGroupJoinedMembershipChanged():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + PeekInfo peekInfo = groupCall.getPeekInfo(); + + if (peekInfo == null) { + return currentState; + } + + if (currentState.getCallSetupState().hasSentJoinedMessage()) { + return currentState; + } + + webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), WebRtcUtil.getGroupCallEraId(groupCall)); + + return currentState.builder() + .changeCallSetupState() + .sentJoinedMessage(true) + .build(); + } + + @Override protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { Log.i(TAG, "handleLocalHangup():"); GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + try { groupCall.disconnect(); } catch (CallException e) { return groupCallFailure(currentState, "Unable to disconnect from group call", e); } + webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), WebRtcUtil.getGroupCallEraId(groupCall)); + currentState = currentState.builder() .changeCallInfoState() .callState(WebRtcViewModel.State.CALL_DISCONNECTED) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java index ac68b8da29..989ef069ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java @@ -8,6 +8,8 @@ import com.annimon.stream.Stream; import org.signal.ringrtc.CallException; import org.signal.ringrtc.GroupCall; +import org.signal.ringrtc.PeekInfo; +import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; @@ -40,6 +42,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor { byte[] groupId = currentState.getCallInfoState().getCallRecipient().requireGroupId().getDecodedId(); GroupCall groupCall = webRtcInteractor.getCallManager().createGroupCall(groupId, + BuildConfig.SIGNAL_SFU_URL, currentState.getVideoState().requireEglBase(), webRtcInteractor.getGroupCallObserver()); @@ -96,8 +99,14 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor { Log.i(tag, "handleGroupJoinedMembershipChanged():"); GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + PeekInfo peekInfo = groupCall.getPeekInfo(); - List callParticipants = Stream.of(groupCall.getJoinedGroupMembers()) + if (peekInfo == null) { + Log.i(tag, "No peek info available"); + return currentState; + } + + List callParticipants = Stream.of(peekInfo.getJoinedMembers()) .map(uuid -> Recipient.externalPush(context, uuid, null, false)) .toList(); @@ -105,7 +114,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor { .changeCallInfoState(); for (Recipient recipient : callParticipants) { - builder.putParticipant(recipient, CallParticipant.createRemote(recipient, null, new BroadcastVideoSink(null), false)); + builder.putParticipant(recipient, CallParticipant.createRemote(recipient, null, new BroadcastVideoSink(null), true, true)); } return builder.build(); 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 137ceb48e8..54ec61e316 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 @@ -9,6 +9,7 @@ import androidx.annotation.Nullable; import org.signal.ringrtc.CallException; import org.signal.ringrtc.CallId; +import org.signal.ringrtc.GroupCall; import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; @@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.ringrtc.CameraState; import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.CallMetadata; +import org.thoughtcrime.securesms.service.webrtc.WebRtcData.GroupCallUpdateMetadata; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.HttpData; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.OfferMetadata; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.ReceivedOfferMetadata; @@ -58,6 +60,8 @@ import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_ import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_SIGNALING_FAILURE; import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_ENDED_TIMEOUT; import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_FLIP_CAMERA; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_CALL_ENDED; +import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_CALL_UPDATE_MESSAGE; import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_JOINED_MEMBERSHIP_CHANGED; import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_LOCAL_DEVICE_STATE_CHANGED; import static org.thoughtcrime.securesms.service.WebRtcCallService.ACTION_GROUP_REMOTE_DEVICE_STATE_CHANGED; @@ -116,6 +120,8 @@ import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getCa import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getEnable; import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getErrorCallState; import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getErrorIdentityKey; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getGroupCallEndReason; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getGroupCallHash; import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getGroupMembershipToken; import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getIceCandidates; import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getIceServers; @@ -227,9 +233,11 @@ public abstract class WebRtcActionProcessor { case ACTION_GROUP_LOCAL_DEVICE_STATE_CHANGED: return handleGroupLocalDeviceStateChanged(currentState); case ACTION_GROUP_REMOTE_DEVICE_STATE_CHANGED: return handleGroupRemoteDeviceStateChanged(currentState); case ACTION_GROUP_JOINED_MEMBERSHIP_CHANGED: return handleGroupJoinedMembershipChanged(currentState); - case ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF: return handleGroupRequestMembershipProof(currentState, getGroupMembershipToken(intent)); + case ACTION_GROUP_REQUEST_MEMBERSHIP_PROOF: return handleGroupRequestMembershipProof(currentState, getGroupCallHash(intent), getGroupMembershipToken(intent)); case ACTION_GROUP_REQUEST_UPDATE_MEMBERS: return handleGroupRequestUpdateMembers(currentState); case ACTION_GROUP_UPDATE_RENDERED_RESOLUTIONS: return handleUpdateRenderedResolutions(currentState); + case ACTION_GROUP_CALL_ENDED: return handleGroupCallEnded(currentState, getGroupCallHash(intent), getGroupCallEndReason(intent)); + case ACTION_GROUP_CALL_UPDATE_MESSAGE: return handleGroupCallUpdateMessage(currentState, GroupCallUpdateMetadata.fromIntent(intent)); case ACTION_HTTP_SUCCESS: return handleHttpSuccess(currentState, HttpData.fromIntent(intent)); case ACTION_HTTP_FAILURE: return handleHttpFailure(currentState, HttpData.fromIntent(intent)); @@ -694,7 +702,7 @@ public abstract class WebRtcActionProcessor { return currentState; } - protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, @NonNull byte[] groupMembershipToken) { + protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull byte[] groupMembershipToken) { Log.i(tag, "handleGroupRequestMembershipProof not processed"); return currentState; } @@ -709,6 +717,16 @@ public abstract class WebRtcActionProcessor { return currentState; } + protected @NonNull WebRtcServiceState handleGroupCallEnded(@NonNull WebRtcServiceState currentState, int groupCallHash, @NonNull GroupCall.GroupCallEndReason groupCallEndReason) { + Log.i(tag, "handleGroupCallEnded not processed"); + return currentState; + } + + protected @NonNull WebRtcServiceState handleGroupCallUpdateMessage(@NonNull WebRtcServiceState currentState, @NonNull GroupCallUpdateMetadata groupCallUpdateMetadata) { + webRtcInteractor.peekGroupCall(groupCallUpdateMetadata); + return currentState; + } + //endregion protected @NonNull WebRtcServiceState handleHttpSuccess(@NonNull WebRtcServiceState currentState, @NonNull HttpData httpData) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcData.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcData.java index 10e48fdcee..79d46d35f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcData.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcData.java @@ -7,12 +7,16 @@ import androidx.annotation.Nullable; import org.signal.ringrtc.CallId; import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.whispersystems.signalservice.api.messages.calls.HangupMessage; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import java.util.UUID; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_ERA_ID; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_UPDATE_GROUP; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_UPDATE_SENDER; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HANGUP_DEVICE_ID; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HANGUP_IS_LEGACY; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HANGUP_TYPE; @@ -22,6 +26,7 @@ import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_HTTP_RE import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_MESSAGE_AGE_SECONDS; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_SERVER_DELIVERED_TIMESTAMP; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_SERVER_RECEIVED_TIMESTAMP; +import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getRecipientId; import static org.thoughtcrime.securesms.service.webrtc.WebRtcIntentParser.getRemoteDevice; /** @@ -304,4 +309,44 @@ public class WebRtcData { return messageAgeSeconds; } } + + /** + * Metadata associated with a group call update message. + */ + public static class GroupCallUpdateMetadata { + private final RecipientId sender; + private final RecipientId groupRecipientId; + private final String groupCallEraId; + private final long serverReceivedTimestamp; + + static @NonNull GroupCallUpdateMetadata fromIntent(@NonNull Intent intent) { + return new GroupCallUpdateMetadata(getRecipientId(intent, EXTRA_GROUP_CALL_UPDATE_SENDER), + getRecipientId(intent, EXTRA_GROUP_CALL_UPDATE_GROUP), + intent.getStringExtra(EXTRA_GROUP_CALL_ERA_ID), + intent.getLongExtra(EXTRA_SERVER_RECEIVED_TIMESTAMP, 0)); + } + + public GroupCallUpdateMetadata(@NonNull RecipientId sender, @NonNull RecipientId groupRecipientId, @Nullable String groupCallEraId, long serverReceivedTimestamp) { + this.sender = sender; + this.groupRecipientId = groupRecipientId; + this.groupCallEraId = groupCallEraId; + this.serverReceivedTimestamp = serverReceivedTimestamp; + } + + public @NonNull RecipientId getSender() { + return sender; + } + + public @NonNull RecipientId getGroupRecipientId() { + return groupRecipientId; + } + + public @Nullable String getGroupCallEraId() { + return groupCallEraId; + } + + public long getServerReceivedTimestamp() { + return serverReceivedTimestamp; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcIntentParser.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcIntentParser.java index 7b695f3034..47d8f55824 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcIntentParser.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcIntentParser.java @@ -6,9 +6,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.ringrtc.CallId; +import org.signal.ringrtc.GroupCall; import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.ringrtc.CameraState; import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; import org.thoughtcrime.securesms.ringrtc.RemotePeer; @@ -36,6 +38,8 @@ import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_CAMERA_ import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ENABLE; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ERROR_CALL_STATE; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ERROR_IDENTITY_KEY; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_END_REASON; +import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_CALL_HASH; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_GROUP_EXTERNAL_TOKEN; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_ICE_CANDIDATES; import static org.thoughtcrime.securesms.service.WebRtcCallService.EXTRA_MULTI_RING; @@ -174,15 +178,34 @@ public final class WebRtcIntentParser { public static @NonNull CameraState getCameraState(@NonNull Intent intent) { return Objects.requireNonNull(intent.getParcelableExtra(EXTRA_CAMERA_STATE)); } + public static @NonNull WebRtcViewModel.State getErrorCallState(@NonNull Intent intent) { return (WebRtcViewModel.State) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_ERROR_CALL_STATE)); } public static @NonNull Optional getErrorIdentityKey(@NonNull Intent intent) { - IdentityKeyParcelable identityKeyParcelable = (IdentityKeyParcelable) intent.getParcelableExtra(EXTRA_ERROR_IDENTITY_KEY); + IdentityKeyParcelable identityKeyParcelable = intent.getParcelableExtra(EXTRA_ERROR_IDENTITY_KEY); if (identityKeyParcelable != null) { return Optional.fromNullable(identityKeyParcelable.get()); } return Optional.absent(); } + + public static int getGroupCallHash(@NonNull Intent intent) { + return intent.getIntExtra(EXTRA_GROUP_CALL_HASH, 0); + } + + public static @NonNull GroupCall.GroupCallEndReason getGroupCallEndReason(@NonNull Intent intent) { + int ordinal = intent.getIntExtra(EXTRA_GROUP_CALL_END_REASON, -1); + + if (ordinal >= 0 && ordinal < GroupCall.GroupCallEndReason.values().length) { + return GroupCall.GroupCallEndReason.values()[ordinal]; + } + + return GroupCall.GroupCallEndReason.DEVICE_EXPLICITLY_DISCONNECTED; + } + + public static @NonNull RecipientId getRecipientId(@NonNull Intent intent, @NonNull String name) { + return RecipientId.from(Objects.requireNonNull(intent.getStringExtra(name))); + } } 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 97e2fc6a97..231a60b62f 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 @@ -8,7 +8,6 @@ import androidx.annotation.Nullable; import org.signal.ringrtc.CallManager; import org.signal.ringrtc.GroupCall; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.ringrtc.CameraEventListener; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.WebRtcCallService; @@ -89,6 +88,10 @@ public class WebRtcInteractor { webRtcCallService.sendOpaqueCallMessage(uuid, callMessage); } + void sendGroupCallMessage(@NonNull Recipient recipient, @Nullable String groupCallEraId) { + webRtcCallService.sendGroupCallMessage(recipient, groupCallEraId); + } + void setCallInProgressNotification(int type, @NonNull RemotePeer remotePeer) { webRtcCallService.setCallInProgressNotification(type, remotePeer.getRecipient()); } @@ -144,4 +147,8 @@ public class WebRtcInteractor { void startAudioCommunication(boolean preserveSpeakerphone) { audioManager.startCommunication(preserveSpeakerphone); } + + void peekGroupCall(@NonNull WebRtcData.GroupCallUpdateMetadata groupCallUpdateMetadata) { + webRtcCallService.peekGroupCall(groupCallUpdateMetadata); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java index 31384f3783..5d9215f616 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcUtil.java @@ -4,9 +4,11 @@ import android.content.Context; import android.media.AudioManager; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.signal.ringrtc.CallManager; import org.signal.ringrtc.GroupCall; +import org.signal.ringrtc.PeekInfo; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.webrtc.locks.LockManager; @@ -66,4 +68,13 @@ public final class WebRtcUtil { return WebRtcViewModel.GroupCallState.DISCONNECTED; } } + + public static @Nullable String getGroupCallEraId(@Nullable GroupCall groupCall) { + if (groupCall == null) { + return null; + } + + PeekInfo peekInfo = groupCall.getPeekInfo(); + return peekInfo != null ? peekInfo.getEraId() : null; + } } 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 index a4b19e69ba..a6167b912d 100644 --- 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 @@ -9,19 +9,21 @@ public final class CallSetupState { boolean enableVideoOnCreate; boolean isRemoteVideoOffer; boolean acceptWithVideo; + boolean sentJoinedMessage; public CallSetupState() { - this(false, false, false); + this(false, false, false, false); } public CallSetupState(@NonNull CallSetupState toCopy) { - this(toCopy.enableVideoOnCreate, toCopy.isRemoteVideoOffer, toCopy.acceptWithVideo); + this(toCopy.enableVideoOnCreate, toCopy.isRemoteVideoOffer, toCopy.acceptWithVideo, toCopy.sentJoinedMessage); } - public CallSetupState(boolean enableVideoOnCreate, boolean isRemoteVideoOffer, boolean acceptWithVideo) { + 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() { @@ -35,4 +37,8 @@ public final class CallSetupState { public boolean isAcceptWithVideo() { return acceptWithVideo; } + + public boolean hasSentJoinedMessage() { + return sentJoinedMessage; + } } 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 89c5b20bdd..99ef75a81f 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 @@ -127,6 +127,11 @@ public class WebRtcServiceStateBuilder { toBuild.acceptWithVideo = acceptWithVideo; return this; } + + public @NonNull CallSetupStateBuilder sentJoinedMessage(boolean sentJoinedMessage) { + toBuild.sentJoinedMessage = sentJoinedMessage; + return this; + } } public class VideoStateBuilder { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index a0e4337a06..2c425954a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -44,15 +44,25 @@ public final class AvatarUtil { } public static void loadBlurredIconIntoViewBackground(@NonNull Recipient recipient, @NonNull View target) { + loadBlurredIconIntoViewBackground(recipient, target, false); + } + + public static void loadBlurredIconIntoViewBackground(@NonNull Recipient recipient, @NonNull View target, boolean useSelfProfileAvatar) { Context context = target.getContext(); - if (recipient.getContactPhoto() == null) { + ContactPhoto photo; + + if (recipient.isSelf() && useSelfProfileAvatar) { + photo = new ProfileContactPhoto(Recipient.self(), Recipient.self().getProfileAvatar()); + } else if (recipient.getContactPhoto() == null) { target.setBackgroundColor(ContextCompat.getColor(target.getContext(), R.color.black)); return; + } else { + photo = recipient.getContactPhoto(); } GlideApp.with(target) - .load(recipient.getContactPhoto()) + .load(photo) .transform(new CenterCrop(), new BlurTransformation(context, 0.25f, BlurTransformation.MAX_RADIUS)) .into(new CustomViewTarget(target) { @Override diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index ad1937d18f..85d7d2e3c5 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -69,3 +69,10 @@ message BodyRangeList { repeated BodyRange ranges = 1; } + +message GroupCallUpdateDetails { + string eraId = 1; + string startedCallUuid = 2; + int64 startedCallTimestamp = 3; + repeated string inCallUuids = 4; +} diff --git a/app/src/main/res/drawable-night/ic_group_24.xml b/app/src/main/res/drawable-night/ic_group_24.xml index 5d7e9219ed..c5a6844f70 100644 --- a/app/src/main/res/drawable-night/ic_group_24.xml +++ b/app/src/main/res/drawable-night/ic_group_24.xml @@ -1,9 +1,4 @@ - - - + + + + diff --git a/app/src/main/res/drawable-night/ic_video_16.xml b/app/src/main/res/drawable-night/ic_video_16.xml new file mode 100644 index 0000000000..6db01b7da0 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_video_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_solid_24.xml b/app/src/main/res/drawable/ic_group_solid_24.xml new file mode 100644 index 0000000000..5d7e9219ed --- /dev/null +++ b/app/src/main/res/drawable/ic_group_solid_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_off_solid_18.xml b/app/src/main/res/drawable/ic_mic_off_solid_18.xml new file mode 100644 index 0000000000..215c565cc8 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off_solid_18.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_16.xml b/app/src/main/res/drawable/ic_video_16.xml new file mode 100644 index 0000000000..9a2e3b0747 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_off_solid_white_18.xml b/app/src/main/res/drawable/ic_video_off_solid_white_18.xml new file mode 100644 index 0000000000..47a40f91d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_off_solid_white_18.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/call_participant_item.xml b/app/src/main/res/layout/call_participant_item.xml index 38bd5682c6..334ff885a4 100644 --- a/app/src/main/res/layout/call_participant_item.xml +++ b/app/src/main/res/layout/call_participant_item.xml @@ -38,4 +38,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + diff --git a/app/src/main/res/layout/call_participants_list_item.xml b/app/src/main/res/layout/call_participants_list_item.xml index c6a7dc5173..fb56ecc964 100644 --- a/app/src/main/res/layout/call_participants_list_item.xml +++ b/app/src/main/res/layout/call_participants_list_item.xml @@ -21,7 +21,8 @@ + + + + diff --git a/app/src/main/res/layout/show_participants_menu_view.xml b/app/src/main/res/layout/show_participants_menu_view.xml new file mode 100644 index 0000000000..b9d5fa7daa --- /dev/null +++ b/app/src/main/res/layout/show_participants_menu_view.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/group_call.xml b/app/src/main/res/menu/group_call.xml index 9c383ba2c0..37613907ef 100644 --- a/app/src/main/res/menu/group_call.xml +++ b/app/src/main/res/menu/group_call.xml @@ -5,9 +5,8 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 562416106d..5fd8f994ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1199,6 +1199,20 @@ You marked your safety number with %s unverified You marked your safety number with %s unverified from another device + + %1$s started a group call · %2$s + %1$s is in the group call · %2$s + You are in the group call · %2$s + %1$s and %2$s are in the group call · %3$s + %1$s, %2$s, and %3$s are in the group call · %4$s + Group call · %1$s + You + + + %1$s, %2$s, and %3$d other are in the group call · %4$s + %1$s, %2$s, and %3$d others are in the group call · %4$s + + Accept Continue @@ -1884,6 +1898,8 @@ Loading Learn more + Join call + Return to call Play … Pause diff --git a/app/witness-verifications.gradle b/app/witness-verifications.gradle index 24fe652c8d..ad0a27ef87 100644 --- a/app/witness-verifications.gradle +++ b/app/witness-verifications.gradle @@ -438,8 +438,8 @@ dependencyVerification { ['org.signal:argon2:13.1', '0f686ccff0d4842bfcc74d92e8dc780a5f159b9376e37a1189fabbcdac458bef'], - ['org.signal:ringrtc-android:2.8.0', - '49965467ed1e8634969af10f318bbaf1dd97b1d55bca05300802a5dcb6ed21a3'], + ['org.signal:ringrtc-android:2.8.3', + '1cdc73ec34b11b9eeb0a650715e1095cade226736192c091991f31367245e37a'], ['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 be8e3a35c5..72ee050a2e 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 @@ -713,6 +713,10 @@ public class SignalServiceMessageSender { builder.setDelete(delete); } + if (message.getGroupCallUpdate().isPresent()) { + builder.setGroupCallUpdate(DataMessage.GroupCallUpdate.newBuilder().setEraId(message.getGroupCallUpdate().get().getEraId())); + } + builder.setTimestamp(message.getTimestamp()); return enforceMaxContentSize(container.setDataMessage(builder).build().toByteArray()); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index df2993021d..2bbf94e1d7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -342,18 +342,19 @@ public final class SignalServiceContent { } - List attachments = new LinkedList<>(); - boolean endSession = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.END_SESSION_VALUE ) != 0); - boolean expirationUpdate = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0); - boolean profileKeyUpdate = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.PROFILE_KEY_UPDATE_VALUE ) != 0); - boolean isGroupV2 = groupInfoV2 != null; - SignalServiceDataMessage.Quote quote = createQuote(content, isGroupV2); - List sharedContacts = createSharedContacts(content); - List previews = createPreviews(content); - List mentions = createMentions(content.getBodyRangesList(), content.getBody(), isGroupV2); - SignalServiceDataMessage.Sticker sticker = createSticker(content); - SignalServiceDataMessage.Reaction reaction = createReaction(content); - SignalServiceDataMessage.RemoteDelete remoteDelete = createRemoteDelete(content); + List attachments = new LinkedList<>(); + boolean endSession = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.END_SESSION_VALUE ) != 0); + boolean expirationUpdate = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0); + boolean profileKeyUpdate = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.PROFILE_KEY_UPDATE_VALUE ) != 0); + boolean isGroupV2 = groupInfoV2 != null; + SignalServiceDataMessage.Quote quote = createQuote(content, isGroupV2); + List sharedContacts = createSharedContacts(content); + List previews = createPreviews(content); + List mentions = createMentions(content.getBodyRangesList(), content.getBody(), isGroupV2); + SignalServiceDataMessage.Sticker sticker = createSticker(content); + SignalServiceDataMessage.Reaction reaction = createReaction(content); + SignalServiceDataMessage.RemoteDelete remoteDelete = createRemoteDelete(content); + SignalServiceDataMessage.GroupCallUpdate groupCallUpdate = createGroupCallUpdate(content); if (content.getRequiredProtocolVersion() > SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT_VALUE) { throw new UnsupportedDataMessageProtocolVersionException(SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT_VALUE, @@ -389,7 +390,8 @@ public final class SignalServiceContent { sticker, content.getIsViewOnce(), reaction, - remoteDelete); + remoteDelete, + groupCallUpdate); } private static SignalServiceSyncMessage createSynchronizeMessage(SignalServiceMetadata metadata, @@ -787,6 +789,16 @@ public final class SignalServiceContent { return new SignalServiceDataMessage.RemoteDelete(delete.getTargetSentTimestamp()); } + private static SignalServiceDataMessage.GroupCallUpdate createGroupCallUpdate(SignalServiceProtos.DataMessage content) { + if (!content.hasGroupCallUpdate()) { + return null; + } + + SignalServiceProtos.DataMessage.GroupCallUpdate groupCallUpdate = content.getGroupCallUpdate(); + + return new SignalServiceDataMessage.GroupCallUpdate(groupCallUpdate.getEraId()); + } + private static List createSharedContacts(SignalServiceProtos.DataMessage content) throws ProtocolInvalidMessageException { if (content.getContactCount() <= 0) return null; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java index 93df5e0330..493ed0e7dd 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java @@ -38,6 +38,7 @@ public class SignalServiceDataMessage { private final boolean viewOnce; private final Optional reaction; private final Optional remoteDelete; + private final Optional groupCallUpdate; /** * Construct a SignalServiceDataMessage. @@ -56,7 +57,8 @@ public class SignalServiceDataMessage { String body, boolean endSession, int expiresInSeconds, boolean expirationUpdate, byte[] profileKey, boolean profileKeyUpdate, Quote quote, List sharedContacts, List previews, - List mentions, Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete) + List mentions, Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete, + GroupCallUpdate groupCallUpdate) { try { this.group = SignalServiceGroupContext.createOptional(group, groupV2); @@ -64,18 +66,19 @@ public class SignalServiceDataMessage { throw new AssertionError(e); } - this.timestamp = timestamp; - this.body = OptionalUtil.absentIfEmpty(body); - this.endSession = endSession; - this.expiresInSeconds = expiresInSeconds; - this.expirationUpdate = expirationUpdate; - this.profileKey = Optional.fromNullable(profileKey); - this.profileKeyUpdate = profileKeyUpdate; - this.quote = Optional.fromNullable(quote); - this.sticker = Optional.fromNullable(sticker); - this.viewOnce = viewOnce; - this.reaction = Optional.fromNullable(reaction); - this.remoteDelete = Optional.fromNullable(remoteDelete); + this.timestamp = timestamp; + this.body = OptionalUtil.absentIfEmpty(body); + this.endSession = endSession; + this.expiresInSeconds = expiresInSeconds; + this.expirationUpdate = expirationUpdate; + this.profileKey = Optional.fromNullable(profileKey); + this.profileKeyUpdate = profileKeyUpdate; + this.quote = Optional.fromNullable(quote); + this.sticker = Optional.fromNullable(sticker); + this.viewOnce = viewOnce; + this.reaction = Optional.fromNullable(reaction); + this.remoteDelete = Optional.fromNullable(remoteDelete); + this.groupCallUpdate = Optional.fromNullable(groupCallUpdate); if (attachments != null && !attachments.isEmpty()) { this.attachments = Optional.of(attachments); @@ -220,6 +223,10 @@ public class SignalServiceDataMessage { return remoteDelete; } + public Optional getGroupCallUpdate() { + return groupCallUpdate; + } + public static class Builder { private List attachments = new LinkedList<>(); @@ -241,6 +248,7 @@ public class SignalServiceDataMessage { private boolean viewOnce; private Reaction reaction; private RemoteDelete remoteDelete; + private GroupCallUpdate groupCallUpdate; private Builder() {} @@ -358,12 +366,18 @@ public class SignalServiceDataMessage { return this; } + public Builder withGroupCallUpdate(GroupCallUpdate groupCallUpdate) { + this.groupCallUpdate = groupCallUpdate; + return this; + } + public SignalServiceDataMessage build() { if (timestamp == 0) timestamp = System.currentTimeMillis(); return new SignalServiceDataMessage(timestamp, group, groupV2, attachments, body, endSession, expiresInSeconds, expirationUpdate, profileKey, profileKeyUpdate, quote, sharedContacts, previews, - mentions, sticker, viewOnce, reaction, remoteDelete); + mentions, sticker, viewOnce, reaction, remoteDelete, + groupCallUpdate); } } @@ -564,4 +578,16 @@ public class SignalServiceDataMessage { return length; } } + + public static class GroupCallUpdate { + private final String eraId; + + public GroupCallUpdate(String eraId) { + this.eraId = eraId; + } + + public String getEraId() { + return eraId; + } + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index ec72be29e3..686b2c8ae8 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -215,6 +215,8 @@ public class PushServiceSocket { private static final long CDN2_RESUMABLE_LINK_LIFETIME_MILLIS = TimeUnit.DAYS.toMillis(7); + private static final int MAX_FOLLOW_UPS = 20; + private long soTimeoutMillis = TimeUnit.SECONDS.toMillis(30); private final Set connections = new HashSet<>(); @@ -1672,7 +1674,7 @@ public class PushServiceSocket { ConnectionHolder connectionHolder = getRandom(serviceClients, random); OkHttpClient okHttpClient = connectionHolder.getClient() .newBuilder() - .followRedirects(true) + .followRedirects(false) .connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) .build(); @@ -1688,22 +1690,36 @@ public class PushServiceSocket { } } - Call call = okHttpClient.newCall(builder.build()); + Request request = builder.build(); - try { - Response response = call.execute(); - int responseStatus = response.code(); - byte[] responseBody = response.body() != null ? response.body().bytes() : new byte[0]; + for (int i = 0; i < MAX_FOLLOW_UPS; i++) { + try (Response response = okHttpClient.newCall(request).execute()) { + int responseStatus = response.code(); - return new CallingResponse.Success(requestId, responseStatus, responseBody); - } catch (IOException e) { - Log.w(TAG, "Exception during ringrtc http call.", e); - return new CallingResponse.Error(requestId, e); + if (responseStatus != 307) { + return new CallingResponse.Success(requestId, + responseStatus, + response.body() != null ? response.body().bytes() : new byte[0]); + } + + String location = response.header("Location"); + HttpUrl newUrl = location != null ? request.url().resolve(location) : null; + + if (newUrl != null) { + request = request.newBuilder().url(newUrl).build(); + } else { + return new CallingResponse.Error(requestId, new IOException("Received redirect without a valid Location header")); + } + } catch (IOException e) { + Log.w(TAG, "Exception during ringrtc http call.", e); + return new CallingResponse.Error(requestId, e); + } } + + Log.w(TAG, "Calling request max redirects exceeded"); + return new CallingResponse.Error(requestId, new IOException("Redirect limit exceeded")); } - - private ServiceConnectionHolder[] createServiceConnectionHolders(SignalUrl[] urls, List interceptors, Optional dns) diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index 1455de85e1..74ff8864e1 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -234,6 +234,10 @@ message DataMessage { optional uint64 targetSentTimestamp = 1; } + message GroupCallUpdate { + optional string eraId = 1; + } + enum ProtocolVersion { option allow_alias = true; @@ -264,6 +268,7 @@ message DataMessage { optional Reaction reaction = 16; optional Delete delete = 17; repeated BodyRange bodyRanges = 18; + optional GroupCallUpdate groupCallUpdate = 19; } message NullMessage {