Group link preview and info display bottom sheet.
parent
477bb45df7
commit
09d167c16d
|
@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheet
|
|||
import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
|
@ -193,6 +194,23 @@ public final class AvatarImageView extends AppCompatImageView {
|
|||
}
|
||||
}
|
||||
|
||||
public void setImageBytesForGroup(@Nullable byte[] avatarBytes,
|
||||
@Nullable Recipient.FallbackPhotoProvider fallbackPhotoProvider,
|
||||
@NonNull MaterialColor color)
|
||||
{
|
||||
Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
|
||||
.getPhotoForGroup()
|
||||
.asDrawable(getContext(), color.toAvatarColor(getContext()));
|
||||
|
||||
GlideApp.with(this)
|
||||
.load(avatarBytes)
|
||||
.fallback(fallback)
|
||||
.error(fallback)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.circleCrop()
|
||||
.into(this);
|
||||
}
|
||||
|
||||
private static class RecipientContactPhoto {
|
||||
|
||||
private final @NonNull Recipient recipient;
|
||||
|
|
|
@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
|||
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
@ -236,9 +237,9 @@ public class InputPanel extends LinearLayout
|
|||
this.linkPreview.setLoading();
|
||||
}
|
||||
|
||||
public void setLinkPreviewNoPreview() {
|
||||
public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) {
|
||||
this.linkPreview.setVisibility(View.VISIBLE);
|
||||
this.linkPreview.setNoPreview();
|
||||
this.linkPreview.setNoPreview(customError);
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional<LinkPreview> preview) {
|
||||
|
|
|
@ -4,16 +4,19 @@ import android.content.Context;
|
|||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
|
@ -36,7 +39,7 @@ public class LinkPreviewView extends FrameLayout {
|
|||
private View divider;
|
||||
private View closeButton;
|
||||
private View spinner;
|
||||
private View noPreview;
|
||||
private TextView noPreview;
|
||||
|
||||
private int type;
|
||||
private int defaultRadius;
|
||||
|
@ -110,12 +113,13 @@ public class LinkPreviewView extends FrameLayout {
|
|||
noPreview.setVisibility(INVISIBLE);
|
||||
}
|
||||
|
||||
public void setNoPreview() {
|
||||
public void setNoPreview(@Nullable LinkPreviewRepository.Error customError) {
|
||||
title.setVisibility(GONE);
|
||||
site.setVisibility(GONE);
|
||||
thumbnail.setVisibility(GONE);
|
||||
spinner.setVisibility(GONE);
|
||||
noPreview.setVisibility(VISIBLE);
|
||||
noPreview.setText(getLinkPreviewErrorString(customError));
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
|
||||
|
@ -156,6 +160,11 @@ public class LinkPreviewView extends FrameLayout {
|
|||
thumbnail.setDownloadClickListener(listener);
|
||||
}
|
||||
|
||||
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {
|
||||
return customError == LinkPreviewRepository.Error.GROUP_LINK_INACTIVE ? R.string.LinkPreviewView_this_group_link_is_not_active
|
||||
: R.string.LinkPreviewView_no_link_preview_available;
|
||||
}
|
||||
|
||||
public interface CloseClickedListener {
|
||||
void onCloseClicked();
|
||||
}
|
||||
|
|
|
@ -1833,7 +1833,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
inputPanel.setLinkPreviewLoading();
|
||||
} else if (previewState.hasLinks() && !previewState.getLinkPreview().isPresent()) {
|
||||
Log.d(TAG, "No preview found.");
|
||||
inputPanel.setLinkPreviewNoPreview();
|
||||
inputPanel.setLinkPreviewNoPreview(previewState.getError());
|
||||
} else {
|
||||
Log.d(TAG, "Setting link preview: " + previewState.getLinkPreview().isPresent());
|
||||
inputPanel.setLinkPreview(glideRequests, previewState.getLinkPreview());
|
||||
|
|
|
@ -6,16 +6,20 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
|
@ -254,6 +258,20 @@ public final class GroupManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to get a group's details direct from server bypassing the database.
|
||||
* <p>
|
||||
* Useful when you don't yet have the group in the database locally.
|
||||
*/
|
||||
@WorkerThread
|
||||
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull GroupLinkPassword groupLinkPassword)
|
||||
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||
{
|
||||
return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword);
|
||||
}
|
||||
|
||||
public static class GroupActionResult {
|
||||
private final Recipient groupRecipient;
|
||||
private final long threadId;
|
||||
|
|
|
@ -14,6 +14,7 @@ import org.signal.storageservice.protos.groups.GroupChange;
|
|||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
|
@ -28,6 +29,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase;
|
|||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
@ -41,6 +43,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
|||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
||||
|
@ -86,6 +89,14 @@ final class GroupManagerV2 {
|
|||
this.groupCandidateHelper = new GroupCandidateHelper(context);
|
||||
}
|
||||
|
||||
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password)
|
||||
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||
{
|
||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
|
||||
return groupsV2Api.getGroupJoinInfo(groupSecretParams, password.serialize(), authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupCreator create() throws GroupChangeBusyException {
|
||||
return new GroupCreator(GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
enum FetchGroupDetailsError {
|
||||
GroupLinkNotActive,
|
||||
NetworkError
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
public final class GroupDetails {
|
||||
private final String groupName;
|
||||
private final byte[] avatarBytes;
|
||||
private final int groupMembershipCount;
|
||||
private final boolean requiresAdminApproval;
|
||||
private final int groupRevision;
|
||||
|
||||
public GroupDetails(String groupName,
|
||||
byte[] avatarBytes,
|
||||
int groupMembershipCount,
|
||||
boolean requiresAdminApproval,
|
||||
int groupRevision)
|
||||
{
|
||||
this.groupName = groupName;
|
||||
this.avatarBytes = avatarBytes;
|
||||
this.groupMembershipCount = groupMembershipCount;
|
||||
this.requiresAdminApproval = requiresAdminApproval;
|
||||
this.groupRevision = groupRevision;
|
||||
}
|
||||
|
||||
public String getGroupName() {
|
||||
return groupName;
|
||||
}
|
||||
|
||||
public byte[] getAvatarBytes() {
|
||||
return avatarBytes;
|
||||
}
|
||||
|
||||
public int getGroupMembershipCount() {
|
||||
return groupMembershipCount;
|
||||
}
|
||||
|
||||
public boolean joinRequiresAdminApproval() {
|
||||
return requiresAdminApproval;
|
||||
}
|
||||
|
||||
public int getGroupRevision() {
|
||||
return groupRevision;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogFragment {
|
||||
|
||||
private static final String ARG_GROUP_INVITE_LINK_URL = "group_invite_url";
|
||||
|
||||
private ProgressBar busy;
|
||||
private AvatarImageView avatar;
|
||||
private TextView groupName;
|
||||
private TextView groupDetails;
|
||||
private TextView groupJoinExplain;
|
||||
private Button groupJoinButton;
|
||||
private Button groupCancelButton;
|
||||
|
||||
public static void show(@NonNull FragmentManager manager,
|
||||
@NonNull GroupInviteLinkUrl groupInviteLinkUrl)
|
||||
{
|
||||
GroupJoinBottomSheetDialogFragment fragment = new GroupJoinBottomSheetDialogFragment();
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putString(ARG_GROUP_INVITE_LINK_URL, groupInviteLinkUrl.getUrl());
|
||||
fragment.setArguments(args);
|
||||
|
||||
fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
setStyle(DialogFragment.STYLE_NORMAL,
|
||||
ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet
|
||||
: R.style.Theme_Signal_RoundedBottomSheet_Light);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.group_join_bottom_sheet, container, false);
|
||||
|
||||
groupCancelButton = view.findViewById(R.id.group_join_cancel_button);
|
||||
groupJoinButton = view.findViewById(R.id.group_join_button);
|
||||
busy = view.findViewById(R.id.group_join_busy);
|
||||
avatar = view.findViewById(R.id.group_join_recipient_avatar);
|
||||
groupName = view.findViewById(R.id.group_join_group_name);
|
||||
groupDetails = view.findViewById(R.id.group_join_group_details);
|
||||
groupJoinExplain = view.findViewById(R.id.group_join_explain);
|
||||
|
||||
groupCancelButton.setOnClickListener(v -> dismiss());
|
||||
|
||||
avatar.setImageBytesForGroup(null, new FallbackPhotoProvider(), MaterialColor.STEEL);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
GroupJoinViewModel.Factory factory = new GroupJoinViewModel.Factory(requireContext().getApplicationContext(), getGroupInviteLinkUrl());
|
||||
|
||||
GroupJoinViewModel viewModel = ViewModelProviders.of(this, factory).get(GroupJoinViewModel.class);
|
||||
|
||||
viewModel.getGroupDetails().observe(getViewLifecycleOwner(), details -> {
|
||||
groupName.setText(details.getGroupName());
|
||||
groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount()));
|
||||
groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal);
|
||||
groupJoinButton.setOnClickListener(v -> {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());
|
||||
dismiss();
|
||||
});
|
||||
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message);
|
||||
avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), MaterialColor.STEEL);
|
||||
|
||||
groupJoinButton.setVisibility(View.VISIBLE);
|
||||
groupCancelButton.setVisibility(View.VISIBLE);
|
||||
});
|
||||
|
||||
viewModel.isBusy().observe(getViewLifecycleOwner(), isBusy -> busy.setVisibility(isBusy ? View.VISIBLE : View.GONE));
|
||||
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
|
||||
Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show();
|
||||
dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
protected @NonNull String errorToMessage(FetchGroupDetailsError error) {
|
||||
if (error == FetchGroupDetailsError.GroupLinkNotActive) {
|
||||
return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
|
||||
}
|
||||
return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later);
|
||||
}
|
||||
|
||||
private GroupInviteLinkUrl getGroupInviteLinkUrl() {
|
||||
try {
|
||||
//noinspection ConstantConditions
|
||||
return GroupInviteLinkUrl.fromUrl(requireArguments().getString(ARG_GROUP_INVITE_LINK_URL));
|
||||
} catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
|
||||
BottomSheetUtil.show(manager, tag, this);
|
||||
}
|
||||
|
||||
private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider {
|
||||
@Override
|
||||
public @NonNull FallbackContactPhoto getPhotoForGroup() {
|
||||
return new ResourceContactPhoto(R.drawable.ic_group_outline_48);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
final class GroupJoinRepository {
|
||||
|
||||
private static final String TAG = Log.tag(GroupJoinRepository.class);
|
||||
|
||||
private final Context context;
|
||||
private final GroupInviteLinkUrl groupInviteLinkUrl;
|
||||
|
||||
GroupJoinRepository(@NonNull Context context, @NonNull GroupInviteLinkUrl groupInviteLinkUrl) {
|
||||
this.context = context;
|
||||
this.groupInviteLinkUrl = groupInviteLinkUrl;
|
||||
}
|
||||
|
||||
void getGroupDetails(@NonNull GetGroupDetailsCallback callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
callback.onComplete(getGroupDetails());
|
||||
} catch (IOException e) {
|
||||
callback.onError(FetchGroupDetailsError.NetworkError);
|
||||
} catch (VerificationFailedException | GroupLinkNotActiveException e) {
|
||||
callback.onError(FetchGroupDetailsError.GroupLinkNotActive);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull GroupDetails getGroupDetails()
|
||||
throws VerificationFailedException, IOException, GroupLinkNotActiveException
|
||||
{
|
||||
DecryptedGroupJoinInfo joinInfo = GroupManager.getGroupJoinInfoFromServer(context,
|
||||
groupInviteLinkUrl.getGroupMasterKey(),
|
||||
groupInviteLinkUrl.getPassword());
|
||||
|
||||
byte[] avatarBytes = tryGetAvatarBytes(joinInfo);
|
||||
boolean requiresAdminApproval = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
||||
|
||||
return new GroupDetails(joinInfo.getTitle(),
|
||||
avatarBytes,
|
||||
joinInfo.getMemberCount(),
|
||||
requiresAdminApproval,
|
||||
joinInfo.getRevision());
|
||||
}
|
||||
|
||||
private @Nullable byte[] tryGetAvatarBytes(@NonNull DecryptedGroupJoinInfo joinInfo) {
|
||||
try {
|
||||
return AvatarGroupsV2DownloadJob.downloadGroupAvatarBytes(context, groupInviteLinkUrl.getGroupMasterKey(), joinInfo.getAvatar());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to get group avatar", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface GetGroupDetailsCallback {
|
||||
void onComplete(@NonNull GroupDetails groupDetails);
|
||||
void onError(@NonNull FetchGroupDetailsError error);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MediatorLiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
|
||||
public class GroupJoinViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<GroupDetails> groupDetails = new MutableLiveData<>();
|
||||
private final MutableLiveData<FetchGroupDetailsError> errors = new SingleLiveEvent<>();
|
||||
private final MutableLiveData<Boolean> busy = new MediatorLiveData<>();
|
||||
|
||||
private GroupJoinViewModel(@NonNull GroupJoinRepository repository) {
|
||||
busy.setValue(true);
|
||||
repository.getGroupDetails(new GroupJoinRepository.GetGroupDetailsCallback() {
|
||||
@Override
|
||||
public void onComplete(@NonNull GroupDetails details) {
|
||||
busy.postValue(false);
|
||||
groupDetails.postValue(details);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull FetchGroupDetailsError error) {
|
||||
busy.postValue(false);
|
||||
errors.postValue(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
LiveData<GroupDetails> getGroupDetails() {
|
||||
return groupDetails;
|
||||
}
|
||||
|
||||
LiveData<Boolean> isBusy() {
|
||||
return busy;
|
||||
}
|
||||
|
||||
LiveData<FetchGroupDetailsError> getErrors() {
|
||||
return errors;
|
||||
}
|
||||
|
||||
public static class Factory implements ViewModelProvider.Factory {
|
||||
|
||||
private final Context context;
|
||||
private final GroupInviteLinkUrl groupInviteLinkUrl;
|
||||
|
||||
public Factory(@NonNull Context context, @NonNull GroupInviteLinkUrl groupInviteLinkUrl) {
|
||||
this.context = context;
|
||||
this.groupInviteLinkUrl = groupInviteLinkUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection unchecked
|
||||
return (T) new GroupJoinViewModel(new GroupJoinRepository(context.getApplicationContext(), groupInviteLinkUrl));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
|
|||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.storageservice.protos.groups.GroupInviteLink;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
@ -23,21 +24,31 @@ public final class GroupInviteLinkUrl {
|
|||
private final GroupLinkPassword password;
|
||||
private final String url;
|
||||
|
||||
public static GroupInviteLinkUrl forGroup(@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull DecryptedGroup group)
|
||||
throws GroupLinkPassword.InvalidLengthException
|
||||
{
|
||||
return new GroupInviteLinkUrl(groupMasterKey, GroupLinkPassword.fromBytes(group.getInviteLinkPassword().toByteArray()));
|
||||
}
|
||||
|
||||
public static boolean isGroupLink(@NonNull String urlString) {
|
||||
return getGroupUrl(urlString) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null iff not a group url.
|
||||
* @throws InvalidGroupLinkException If group url, but cannot be parsed.
|
||||
*/
|
||||
public static @Nullable GroupInviteLinkUrl fromUrl(@NonNull String urlString)
|
||||
throws InvalidGroupLinkException, UnknownGroupLinkVersionException
|
||||
{
|
||||
URL url;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch (MalformedURLException e) {
|
||||
URL url = getGroupUrl(urlString);
|
||||
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!GROUP_URL_HOST.equalsIgnoreCase(url.getHost())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!"/".equals(url.getPath()) && url.getPath().length() > 0) {
|
||||
throw new InvalidGroupLinkException("No path was expected in url");
|
||||
}
|
||||
|
@ -67,6 +78,21 @@ public final class GroupInviteLinkUrl {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@link URL} if the host name matches.
|
||||
*/
|
||||
private static URL getGroupUrl(@NonNull String urlString) {
|
||||
try {
|
||||
URL url = new URL(urlString);
|
||||
|
||||
return GROUP_URL_HOST.equalsIgnoreCase(url.getHost())
|
||||
? url
|
||||
: null;
|
||||
} catch (MalformedURLException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private GroupInviteLinkUrl(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) {
|
||||
this.groupMasterKey = groupMasterKey;
|
||||
this.password = password;
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
|
@ -88,32 +92,43 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob {
|
|||
}
|
||||
|
||||
Log.i(TAG, "Downloading new avatar for group " + groupId);
|
||||
byte[] decryptedAvatar = downloadGroupAvatarBytes(context, record.get().requireV2GroupProperties().getGroupMasterKey(), cdnKey);
|
||||
|
||||
attachment = File.createTempFile("avatar", "gv2", context.getCacheDir());
|
||||
attachment.deleteOnExit();
|
||||
|
||||
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
|
||||
byte[] encryptedData;
|
||||
|
||||
try (FileInputStream inputStream = receiver.retrieveGroupsV2ProfileAvatar(cdnKey, attachment, AVATAR_DOWNLOAD_FAIL_SAFE_MAX_SIZE)) {
|
||||
|
||||
encryptedData = new byte[(int) attachment.length()];
|
||||
|
||||
Util.readFully(inputStream, encryptedData);
|
||||
|
||||
GroupsV2Operations operations = ApplicationDependencies.getGroupsV2Operations();
|
||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(record.get().requireV2GroupProperties().getGroupMasterKey());
|
||||
GroupsV2Operations.GroupOperations groupOperations = operations.forGroup(groupSecretParams);
|
||||
byte[] decryptedAvatar = groupOperations.decryptAvatar(encryptedData);
|
||||
|
||||
AvatarHelper.setAvatar(context, record.get().getRecipientId(), decryptedAvatar != null ? new ByteArrayInputStream(decryptedAvatar) : null);
|
||||
database.onAvatarUpdated(groupId, true);
|
||||
}
|
||||
AvatarHelper.setAvatar(context, record.get().getRecipientId(), decryptedAvatar != null ? new ByteArrayInputStream(decryptedAvatar) : null);
|
||||
database.onAvatarUpdated(groupId, true);
|
||||
|
||||
} catch (NonSuccessfulResponseCodeException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable byte[] downloadGroupAvatarBytes(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull String cdnKey)
|
||||
throws IOException
|
||||
{
|
||||
if (cdnKey.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
File attachment = File.createTempFile("avatar", "gv2", context.getCacheDir());
|
||||
attachment.deleteOnExit();
|
||||
|
||||
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
|
||||
byte[] encryptedData;
|
||||
|
||||
try (FileInputStream inputStream = receiver.retrieveGroupsV2ProfileAvatar(cdnKey, attachment, AVATAR_DOWNLOAD_FAIL_SAFE_MAX_SIZE)) {
|
||||
encryptedData = new byte[(int) attachment.length()];
|
||||
|
||||
Util.readFully(inputStream, encryptedData);
|
||||
|
||||
GroupsV2Operations operations = ApplicationDependencies.getGroupsV2Operations();
|
||||
GroupsV2Operations.GroupOperations groupOperations = operations.forGroup(groupSecretParams);
|
||||
|
||||
return groupOperations.decryptAvatar(encryptedData);
|
||||
} finally {
|
||||
if (attachment != null && attachment.exists())
|
||||
if (attachment.exists())
|
||||
if (!attachment.delete()) {
|
||||
Log.w(TAG, "Unable to delete temp avatar file");
|
||||
}
|
||||
|
|
|
@ -7,13 +7,23 @@ import android.net.Uri;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil.OpenGraph;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
|
@ -21,9 +31,12 @@ import org.thoughtcrime.securesms.net.CallRequestController;
|
|||
import org.thoughtcrime.securesms.net.CompositeRequestController;
|
||||
import org.thoughtcrime.securesms.net.RequestController;
|
||||
import org.thoughtcrime.securesms.net.UserAgentInterceptor;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
import org.thoughtcrime.securesms.util.ByteUnit;
|
||||
import org.thoughtcrime.securesms.util.Hex;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
|
@ -33,6 +46,7 @@ import org.whispersystems.libsignal.InvalidMessageException;
|
|||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest.StickerInfo;
|
||||
|
||||
|
@ -65,12 +79,15 @@ public class LinkPreviewRepository {
|
|||
.build();
|
||||
}
|
||||
|
||||
RequestController getLinkPreview(@NonNull Context context, @NonNull String url, @NonNull Callback<Optional<LinkPreview>> callback) {
|
||||
@Nullable RequestController getLinkPreview(@NonNull Context context,
|
||||
@NonNull String url,
|
||||
@NonNull Callback callback)
|
||||
{
|
||||
CompositeRequestController compositeController = new CompositeRequestController();
|
||||
|
||||
if (!LinkPreviewUtil.isValidPreviewUrl(url)) {
|
||||
Log.w(TAG, "Tried to get a link preview for a non-whitelisted domain.");
|
||||
callback.onComplete(Optional.absent());
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
return compositeController;
|
||||
}
|
||||
|
||||
|
@ -78,23 +95,25 @@ public class LinkPreviewRepository {
|
|||
|
||||
if (StickerUrl.isValidShareLink(url)) {
|
||||
metadataController = fetchStickerPackLinkPreview(context, url, callback);
|
||||
} else if (GroupInviteLinkUrl.isGroupLink(url)) {
|
||||
metadataController = fetchGroupLinkPreview(context, url, callback);
|
||||
} else {
|
||||
metadataController = fetchMetadata(url, metadata -> {
|
||||
if (metadata.isEmpty()) {
|
||||
callback.onComplete(Optional.absent());
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.getImageUrl().isPresent()) {
|
||||
callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().get(), Optional.absent())));
|
||||
callback.onSuccess(new LinkPreview(url, metadata.getTitle().get(), Optional.absent()));
|
||||
return;
|
||||
}
|
||||
|
||||
RequestController imageController = fetchThumbnail(metadata.getImageUrl().get(), attachment -> {
|
||||
if (!metadata.getTitle().isPresent() && !attachment.isPresent()) {
|
||||
callback.onComplete(Optional.absent());
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
} else {
|
||||
callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().or(""), attachment)));
|
||||
callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), attachment));
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -106,25 +125,25 @@ public class LinkPreviewRepository {
|
|||
return compositeController;
|
||||
}
|
||||
|
||||
private @NonNull RequestController fetchMetadata(@NonNull String url, Callback<Metadata> callback) {
|
||||
private @NonNull RequestController fetchMetadata(@NonNull String url, Consumer<Metadata> callback) {
|
||||
Call call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build());
|
||||
|
||||
call.enqueue(new okhttp3.Callback() {
|
||||
@Override
|
||||
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
||||
Log.w(TAG, "Request failed.", e);
|
||||
callback.onComplete(Metadata.empty());
|
||||
callback.accept(Metadata.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
||||
if (!response.isSuccessful()) {
|
||||
Log.w(TAG, "Non-successful response. Code: " + response.code());
|
||||
callback.onComplete(Metadata.empty());
|
||||
callback.accept(Metadata.empty());
|
||||
return;
|
||||
} else if (response.body() == null) {
|
||||
Log.w(TAG, "No response body.");
|
||||
callback.onComplete(Metadata.empty());
|
||||
callback.accept(Metadata.empty());
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -138,14 +157,14 @@ public class LinkPreviewRepository {
|
|||
imageUrl = Optional.absent();
|
||||
}
|
||||
|
||||
callback.onComplete(new Metadata(title, imageUrl));
|
||||
callback.accept(new Metadata(title, imageUrl));
|
||||
}
|
||||
});
|
||||
|
||||
return new CallRequestController(call);
|
||||
}
|
||||
|
||||
private @NonNull RequestController fetchThumbnail(@NonNull String imageUrl, @NonNull Callback<Optional<Attachment>> callback) {
|
||||
private @NonNull RequestController fetchThumbnail(@NonNull String imageUrl, @NonNull Consumer<Optional<Attachment>> callback) {
|
||||
Call call = client.newCall(new Request.Builder().url(imageUrl).build());
|
||||
CallRequestController controller = new CallRequestController(call);
|
||||
|
||||
|
@ -163,11 +182,13 @@ public class LinkPreviewRepository {
|
|||
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
|
||||
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.JPEG, MediaUtil.IMAGE_JPEG);
|
||||
|
||||
callback.onComplete(thumbnail);
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
|
||||
callback.accept(thumbnail);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Exception during link preview image retrieval.", e);
|
||||
controller.cancel();
|
||||
callback.onComplete(Optional.absent());
|
||||
callback.accept(Optional.absent());
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -176,7 +197,7 @@ public class LinkPreviewRepository {
|
|||
|
||||
private static RequestController fetchStickerPackLinkPreview(@NonNull Context context,
|
||||
@NonNull String packUrl,
|
||||
@NonNull Callback<Optional<LinkPreview>> callback)
|
||||
@NonNull Callback callback)
|
||||
{
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
|
@ -204,19 +225,86 @@ public class LinkPreviewRepository {
|
|||
|
||||
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
|
||||
|
||||
callback.onComplete(Optional.of(new LinkPreview(packUrl, title, thumbnail)));
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
|
||||
callback.onSuccess(new LinkPreview(packUrl, title, thumbnail));
|
||||
} else {
|
||||
callback.onComplete(Optional.absent());
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
}
|
||||
} catch (IOException | InvalidMessageException | ExecutionException | InterruptedException e) {
|
||||
Log.w(TAG, "Failed to fetch sticker pack link preview.");
|
||||
callback.onComplete(Optional.absent());
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
}
|
||||
});
|
||||
|
||||
return () -> Log.i(TAG, "Cancelled sticker pack link preview fetch -- no effect.");
|
||||
}
|
||||
|
||||
private static RequestController fetchGroupLinkPreview(@NonNull Context context,
|
||||
@NonNull String groupUrl,
|
||||
@NonNull Callback callback)
|
||||
{
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUrl(groupUrl);
|
||||
if (groupInviteLinkUrl == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
GroupMasterKey groupMasterKey = groupInviteLinkUrl.getGroupMasterKey();
|
||||
GroupId.V2 groupId = GroupId.v2(groupMasterKey);
|
||||
Optional<GroupDatabase.GroupRecord> group = DatabaseFactory.getGroupDatabase(context)
|
||||
.getGroup(groupId);
|
||||
|
||||
if (group.isPresent()) {
|
||||
Log.i(TAG, "Creating preview for locally available group");
|
||||
|
||||
GroupDatabase.GroupRecord groupRecord = group.get();
|
||||
String title = groupRecord.getTitle();
|
||||
Optional<Attachment> thumbnail = Optional.absent();
|
||||
|
||||
if (AvatarHelper.hasAvatar(context, groupRecord.getRecipientId())) {
|
||||
Recipient recipient = Recipient.resolved(groupRecord.getRecipientId());
|
||||
Bitmap bitmap = AvatarUtil.loadIconBitmapSquare(context, recipient, 512, 512);
|
||||
|
||||
thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
|
||||
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
}
|
||||
|
||||
callback.onSuccess(new LinkPreview(groupUrl, title, thumbnail));
|
||||
} else {
|
||||
Log.i(TAG, "Group is not locally available for preview generation, fetching from server");
|
||||
|
||||
DecryptedGroupJoinInfo joinInfo = GroupManager.getGroupJoinInfoFromServer(context, groupMasterKey, groupInviteLinkUrl.getPassword());
|
||||
Optional<Attachment> thumbnail = Optional.absent();
|
||||
byte[] avatarBytes = AvatarGroupsV2DownloadJob.downloadGroupAvatarBytes(context, groupMasterKey, joinInfo.getAvatar());
|
||||
|
||||
if (avatarBytes != null) {
|
||||
Bitmap bitmap = BitmapFactory.decodeByteArray(avatarBytes, 0, avatarBytes.length);
|
||||
|
||||
thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
|
||||
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
}
|
||||
|
||||
callback.onSuccess(new LinkPreview(groupUrl, joinInfo.getTitle(), thumbnail));
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException | IOException | VerificationFailedException e) {
|
||||
Log.w(TAG, "Failed to fetch group link preview.", e);
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
} catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
|
||||
Log.w(TAG, "Bad group link.", e);
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
} catch (GroupLinkNotActiveException e) {
|
||||
Log.w(TAG, "Group link not active.", e);
|
||||
callback.onError(Error.GROUP_LINK_INACTIVE);
|
||||
}
|
||||
});
|
||||
|
||||
return () -> Log.i(TAG, "Cancelled group link preview fetch -- no effect.");
|
||||
}
|
||||
|
||||
private static Optional<Attachment> bitmapToAttachment(@Nullable Bitmap bitmap,
|
||||
@NonNull Bitmap.CompressFormat format,
|
||||
@NonNull String contentType)
|
||||
|
@ -277,7 +365,14 @@ public class LinkPreviewRepository {
|
|||
}
|
||||
}
|
||||
|
||||
interface Callback<T> {
|
||||
void onComplete(@NonNull T result);
|
||||
interface Callback {
|
||||
void onSuccess(@NonNull LinkPreview linkPreview);
|
||||
|
||||
void onError(@NonNull Error error);
|
||||
}
|
||||
|
||||
public enum Error {
|
||||
PREVIEW_NOT_AVAILABLE,
|
||||
GROUP_LINK_INACTIVE
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.net.RequestController;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
|
@ -86,21 +88,30 @@ public class LinkPreviewViewModel extends ViewModel {
|
|||
linkPreviewState.setValue(LinkPreviewState.forLoading());
|
||||
|
||||
activeUrl = link.get().getUrl();
|
||||
activeRequest = repository.getLinkPreview(context, link.get().getUrl(), lp -> {
|
||||
Util.runOnMain(() -> {
|
||||
if (!userCanceled) {
|
||||
if (lp.isPresent()) {
|
||||
if (activeUrl != null && activeUrl.equals(lp.get().getUrl())) {
|
||||
linkPreviewState.setValue(LinkPreviewState.forPreview(lp.get()));
|
||||
} else {
|
||||
linkPreviewState.setValue(LinkPreviewState.forNoLinks());
|
||||
activeRequest = repository.getLinkPreview(context, link.get().getUrl(), new LinkPreviewRepository.Callback() {
|
||||
@Override
|
||||
public void onSuccess(@NonNull LinkPreview linkPreview) {
|
||||
Util.runOnMain(() -> {
|
||||
if (!userCanceled) {
|
||||
if (activeUrl != null && activeUrl.equals(linkPreview.getUrl())) {
|
||||
linkPreviewState.setValue(LinkPreviewState.forPreview(linkPreview));
|
||||
} else {
|
||||
linkPreviewState.setValue(LinkPreviewState.forNoLinks());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
linkPreviewState.setValue(LinkPreviewState.forLinksWithNoPreview());
|
||||
}
|
||||
activeRequest = null;
|
||||
});
|
||||
}
|
||||
activeRequest = null;
|
||||
});
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull LinkPreviewRepository.Error error) {
|
||||
Util.runOnMain(() -> {
|
||||
if (!userCanceled) {
|
||||
linkPreviewState.setValue(LinkPreviewState.forLinksWithNoPreview(error));
|
||||
}
|
||||
activeRequest = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -157,30 +168,36 @@ public class LinkPreviewViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
public static class LinkPreviewState {
|
||||
private final boolean isLoading;
|
||||
private final boolean hasLinks;
|
||||
private final Optional<LinkPreview> linkPreview;
|
||||
private final boolean isLoading;
|
||||
private final boolean hasLinks;
|
||||
private final Optional<LinkPreview> linkPreview;
|
||||
private final LinkPreviewRepository.Error error;
|
||||
|
||||
private LinkPreviewState(boolean isLoading, boolean hasLinks, Optional<LinkPreview> linkPreview) {
|
||||
private LinkPreviewState(boolean isLoading,
|
||||
boolean hasLinks,
|
||||
Optional<LinkPreview> linkPreview,
|
||||
@Nullable LinkPreviewRepository.Error error)
|
||||
{
|
||||
this.isLoading = isLoading;
|
||||
this.hasLinks = hasLinks;
|
||||
this.linkPreview = linkPreview;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
private static LinkPreviewState forLoading() {
|
||||
return new LinkPreviewState(true, false, Optional.absent());
|
||||
return new LinkPreviewState(true, false, Optional.absent(), null);
|
||||
}
|
||||
|
||||
private static LinkPreviewState forPreview(@NonNull LinkPreview linkPreview) {
|
||||
return new LinkPreviewState(false, true, Optional.of(linkPreview));
|
||||
return new LinkPreviewState(false, true, Optional.of(linkPreview), null);
|
||||
}
|
||||
|
||||
private static LinkPreviewState forLinksWithNoPreview() {
|
||||
return new LinkPreviewState(false, true, Optional.absent());
|
||||
private static LinkPreviewState forLinksWithNoPreview(@NonNull LinkPreviewRepository.Error error) {
|
||||
return new LinkPreviewState(false, true, Optional.absent(), error);
|
||||
}
|
||||
|
||||
private static LinkPreviewState forNoLinks() {
|
||||
return new LinkPreviewState(false, false, Optional.absent());
|
||||
return new LinkPreviewState(false, false, Optional.absent(), null);
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
|
@ -195,6 +212,10 @@ public class LinkPreviewViewModel extends ViewModel {
|
|||
return linkPreview;
|
||||
}
|
||||
|
||||
public @Nullable LinkPreviewRepository.Error getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
boolean hasContent() {
|
||||
return isLoading || hasLinks;
|
||||
}
|
||||
|
|
|
@ -58,10 +58,11 @@ import static org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBann
|
|||
|
||||
public class Recipient {
|
||||
|
||||
private static final String TAG = Log.tag(Recipient.class);
|
||||
|
||||
public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails(), true);
|
||||
|
||||
private static final FallbackPhotoProvider DEFAULT_FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider();
|
||||
private static final String TAG = Log.tag(Recipient.class);
|
||||
public static final FallbackPhotoProvider DEFAULT_FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider();
|
||||
|
||||
private final RecipientId id;
|
||||
private final boolean resolving;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
|
@ -67,13 +68,22 @@ public final class AvatarUtil {
|
|||
public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) {
|
||||
Context context = target.getContext();
|
||||
|
||||
request(GlideApp.with(context).asDrawable(), context, recipient).into(target);
|
||||
requestCircle(GlideApp.with(context).asDrawable(), context, recipient).into(target);
|
||||
}
|
||||
|
||||
public static Bitmap loadIconBitmapSquare(@NonNull Context context,
|
||||
@NonNull Recipient recipient,
|
||||
int width,
|
||||
int height)
|
||||
throws ExecutionException, InterruptedException
|
||||
{
|
||||
return requestSquare(GlideApp.with(context).asBitmap(), context, recipient).submit(width, height).get();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static IconCompat getIconForNotification(@NonNull Context context, @NonNull Recipient recipient) {
|
||||
try {
|
||||
return IconCompat.createWithBitmap(request(GlideApp.with(context).asBitmap(), context, recipient).submit().get());
|
||||
return IconCompat.createWithBitmap(requestCircle(GlideApp.with(context).asBitmap(), context, recipient).submit().get());
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
return null;
|
||||
}
|
||||
|
@ -88,10 +98,17 @@ public final class AvatarUtil {
|
|||
.diskCacheStrategy(DiskCacheStrategy.ALL);
|
||||
}
|
||||
|
||||
private static <T> GlideRequest<T> requestCircle(@NonNull GlideRequest<T> glideRequest, @NonNull Context context, @NonNull Recipient recipient) {
|
||||
return request(glideRequest, context, recipient).circleCrop();
|
||||
}
|
||||
|
||||
private static <T> GlideRequest<T> requestSquare(@NonNull GlideRequest<T> glideRequest, @NonNull Context context, @NonNull Recipient recipient) {
|
||||
return request(glideRequest, context, recipient).centerCrop();
|
||||
}
|
||||
|
||||
private static <T> GlideRequest<T> request(@NonNull GlideRequest<T> glideRequest, @NonNull Context context, @NonNull Recipient recipient) {
|
||||
return glideRequest.load(new ProfileContactPhoto(recipient, recipient.getProfileAvatar()))
|
||||
.error(getFallback(context, recipient))
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL);
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,9 @@ import org.thoughtcrime.securesms.R;
|
|||
import org.thoughtcrime.securesms.WebRtcCallActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining.GroupJoinBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining.GroupJoinUpdateRequiredBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
@ -194,19 +196,22 @@ public class CommunicationActions {
|
|||
{
|
||||
GroupId.V2 groupId = GroupId.v2(groupInviteLinkUrl.getGroupMasterKey());
|
||||
|
||||
SimpleTask.run(SignalExecutors.BOUNDED, () ->
|
||||
DatabaseFactory.getGroupDatabase(activity)
|
||||
.getGroup(groupId)
|
||||
.transform(groupRecord -> Recipient.resolved(groupRecord.getRecipientId()))
|
||||
.orNull(),
|
||||
recipient -> {
|
||||
if (recipient != null) {
|
||||
CommunicationActions.startConversation(activity, recipient, null);
|
||||
Toast.makeText(activity, R.string.GroupJoinBottomSheetDialogFragment_you_are_already_a_member, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
GroupJoinUpdateRequiredBottomSheetDialogFragment.show(activity.getSupportFragmentManager());
|
||||
}
|
||||
});
|
||||
SimpleTask.run(SignalExecutors.BOUNDED, () -> {
|
||||
GroupDatabase.GroupRecord group = DatabaseFactory.getGroupDatabase(activity)
|
||||
.getGroup(groupId)
|
||||
.orNull();
|
||||
|
||||
return group != null && group.isActive() ? Recipient.resolved(group.getRecipientId())
|
||||
: null;
|
||||
},
|
||||
recipient -> {
|
||||
if (recipient != null) {
|
||||
CommunicationActions.startConversation(activity, recipient, null);
|
||||
Toast.makeText(activity, R.string.GroupJoinBottomSheetDialogFragment_you_are_already_a_member, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
GroupJoinBottomSheetDialogFragment.show(activity.getSupportFragmentManager(), groupInviteLinkUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void startInsecureCallInternal(@NonNull Activity activity, @NonNull Recipient recipient) {
|
||||
|
|
|
@ -30,15 +30,16 @@ import android.os.Build.VERSION_CODES;
|
|||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Telephony;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresPermission;
|
||||
import android.telephony.TelephonyManager;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresPermission;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.mms.pdu_alt.CharacterSets;
|
||||
import com.google.android.mms.pdu_alt.EncodedStringValue;
|
||||
|
@ -339,6 +340,10 @@ public class Util {
|
|||
return Optional.fromNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null);
|
||||
}
|
||||
|
||||
public static @NonNull <T> T firstNonNull(@Nullable T optional, @NonNull T fallback) {
|
||||
return optional != null ? optional : fallback;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static @NonNull <T> T firstNonNull(T ... ts) {
|
||||
for (T t : ts) {
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:theme="@style/Theme.Signal.RoundedBottomSheet.Light">
|
||||
|
||||
<org.thoughtcrime.securesms.components.AvatarImageView
|
||||
android:id="@+id/group_join_recipient_avatar"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/group_join_busy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/group_join_recipient_avatar"
|
||||
app:layout_constraintEnd_toEndOf="@+id/group_join_recipient_avatar"
|
||||
app:layout_constraintStart_toStartOf="@+id/group_join_recipient_avatar"
|
||||
app:layout_constraintTop_toTopOf="@+id/group_join_recipient_avatar"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/group_join_group_name"
|
||||
style="@style/TextAppearance.Signal.Body1.Bold"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textColor="?attr/title_text_color_primary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/group_join_recipient_avatar"
|
||||
tools:text="Parkdale Run Club" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/group_join_group_details"
|
||||
style="@style/Signal.Text.Body"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="?attr/title_text_color_secondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/group_join_group_name"
|
||||
tools:text="Group · 12 members" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/group_join_explain"
|
||||
style="@style/TextAppearance.Signal.Body2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="36dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:textColor="?attr/title_text_color_primary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/group_join_group_details"
|
||||
tools:text="@string/GroupJoinBottomSheetDialogFragment_admin_approval_needed" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/group_join_cancel_button"
|
||||
style="@style/Button.Primary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@android:string/cancel"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/group_join_button"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/group_join_explain"
|
||||
app:layout_constraintVertical_bias="0"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/group_join_button"
|
||||
style="@style/Button.Primary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginStart="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toEndOf="@+id/group_join_cancel_button"
|
||||
app:layout_constraintTop_toBottomOf="@+id/group_join_explain"
|
||||
app:layout_constraintVertical_bias="0"
|
||||
tools:text="@string/GroupJoinBottomSheetDialogFragment_join"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -102,13 +102,13 @@
|
|||
android:id="@+id/linkpreview_no_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:text="@string/LinkPreviewView_no_link_preview_available"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/linkpreview_divider"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/linkpreview_divider"/>
|
||||
tools:text="@string/LinkPreviewView_no_link_preview_available" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
|
|
@ -492,6 +492,7 @@
|
|||
|
||||
<!-- LinkPreviewView -->
|
||||
<string name="LinkPreviewView_no_link_preview_available">No link preview available</string>
|
||||
<string name="LinkPreviewView_this_group_link_is_not_active">This group link is not active</string>
|
||||
|
||||
<!-- PendingMembersActivity -->
|
||||
<string name="PendingMemberInvitesActivity_pending_group_invites">Pending group invites</string>
|
||||
|
@ -647,6 +648,16 @@
|
|||
|
||||
<!-- GroupJoinBottomSheetDialogFragment -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">You are already a member</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_join">Join</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_request_to_join">Request to join</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">This group link is not active</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later">Unable to get group information, please try again later</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Do you want to join this group and share your name and photo with its members?</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_admin_approval_needed">An admin of this group must approve your request before you can join this group. When you request to join, your name and photo will be shared with its members.</string>
|
||||
<plurals name="GroupJoinBottomSheetDialogFragment_group_dot_d_members">
|
||||
<item quantity="one">Group · %1$d member</item>
|
||||
<item quantity="other">Group · %1$d members</item>
|
||||
</plurals>
|
||||
|
||||
<!-- GroupJoinUpdateRequiredBottomSheetDialogFragment -->
|
||||
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal_to_use_group_links">Update Signal to use group links</string>
|
||||
|
|
Loading…
Reference in New Issue