mirror of
https://github.com/oxen-io/session-android.git
synced 2025-06-08 18:18:34 +00:00
Group link preview and info display bottom sheet.
This commit is contained in:
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.recipients.ui.managerecipient.ManageRecipientActivity;
|
||||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
|
|
||||||
import java.util.Objects;
|
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 static class RecipientContactPhoto {
|
||||||
|
|
||||||
private final @NonNull Recipient recipient;
|
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.conversation.ConversationStickerSuggestionAdapter;
|
||||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||||
|
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||||
@ -236,9 +237,9 @@ public class InputPanel extends LinearLayout
|
|||||||
this.linkPreview.setLoading();
|
this.linkPreview.setLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLinkPreviewNoPreview() {
|
public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) {
|
||||||
this.linkPreview.setVisibility(View.VISIBLE);
|
this.linkPreview.setVisibility(View.VISIBLE);
|
||||||
this.linkPreview.setNoPreview();
|
this.linkPreview.setNoPreview(customError);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional<LinkPreview> preview) {
|
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.content.res.TypedArray;
|
||||||
import android.graphics.Canvas;
|
import android.graphics.Canvas;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
import android.widget.TextView;
|
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.R;
|
||||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||||
|
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||||
@ -36,7 +39,7 @@ public class LinkPreviewView extends FrameLayout {
|
|||||||
private View divider;
|
private View divider;
|
||||||
private View closeButton;
|
private View closeButton;
|
||||||
private View spinner;
|
private View spinner;
|
||||||
private View noPreview;
|
private TextView noPreview;
|
||||||
|
|
||||||
private int type;
|
private int type;
|
||||||
private int defaultRadius;
|
private int defaultRadius;
|
||||||
@ -110,12 +113,13 @@ public class LinkPreviewView extends FrameLayout {
|
|||||||
noPreview.setVisibility(INVISIBLE);
|
noPreview.setVisibility(INVISIBLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setNoPreview() {
|
public void setNoPreview(@Nullable LinkPreviewRepository.Error customError) {
|
||||||
title.setVisibility(GONE);
|
title.setVisibility(GONE);
|
||||||
site.setVisibility(GONE);
|
site.setVisibility(GONE);
|
||||||
thumbnail.setVisibility(GONE);
|
thumbnail.setVisibility(GONE);
|
||||||
spinner.setVisibility(GONE);
|
spinner.setVisibility(GONE);
|
||||||
noPreview.setVisibility(VISIBLE);
|
noPreview.setVisibility(VISIBLE);
|
||||||
|
noPreview.setText(getLinkPreviewErrorString(customError));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
|
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
|
||||||
@ -156,6 +160,11 @@ public class LinkPreviewView extends FrameLayout {
|
|||||||
thumbnail.setDownloadClickListener(listener);
|
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 {
|
public interface CloseClickedListener {
|
||||||
void onCloseClicked();
|
void onCloseClicked();
|
||||||
}
|
}
|
||||||
|
@ -1833,7 +1833,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||||||
inputPanel.setLinkPreviewLoading();
|
inputPanel.setLinkPreviewLoading();
|
||||||
} else if (previewState.hasLinks() && !previewState.getLinkPreview().isPresent()) {
|
} else if (previewState.hasLinks() && !previewState.getLinkPreview().isPresent()) {
|
||||||
Log.d(TAG, "No preview found.");
|
Log.d(TAG, "No preview found.");
|
||||||
inputPanel.setLinkPreviewNoPreview();
|
inputPanel.setLinkPreviewNoPreview(previewState.getError());
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Setting link preview: " + previewState.getLinkPreview().isPresent());
|
Log.d(TAG, "Setting link preview: " + previewState.getLinkPreview().isPresent());
|
||||||
inputPanel.setLinkPreview(glideRequests, previewState.getLinkPreview());
|
inputPanel.setLinkPreview(glideRequests, previewState.getLinkPreview());
|
||||||
|
@ -6,16 +6,20 @@ import androidx.annotation.NonNull;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.WorkerThread;
|
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.GroupMasterKey;
|
||||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
|
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
|
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Collection;
|
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 {
|
public static class GroupActionResult {
|
||||||
private final Recipient groupRecipient;
|
private final Recipient groupRecipient;
|
||||||
private final long threadId;
|
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.Member;
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
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.DecryptedMember;
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||||
import org.signal.zkgroup.InvalidInputException;
|
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.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
|
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.groups.v2.processing.GroupsV2StateProcessor;
|
||||||
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
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.DecryptedGroupUtil;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
|
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
|
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.GroupsV2Api;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
||||||
@ -86,6 +89,14 @@ final class GroupManagerV2 {
|
|||||||
this.groupCandidateHelper = new GroupCandidateHelper(context);
|
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
|
@WorkerThread
|
||||||
GroupCreator create() throws GroupChangeBusyException {
|
GroupCreator create() throws GroupChangeBusyException {
|
||||||
return new GroupCreator(GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
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 com.google.protobuf.ByteString;
|
||||||
|
|
||||||
import org.signal.storageservice.protos.groups.GroupInviteLink;
|
import org.signal.storageservice.protos.groups.GroupInviteLink;
|
||||||
|
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||||
import org.signal.zkgroup.InvalidInputException;
|
import org.signal.zkgroup.InvalidInputException;
|
||||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||||
import org.whispersystems.util.Base64UrlSafe;
|
import org.whispersystems.util.Base64UrlSafe;
|
||||||
@ -23,21 +24,31 @@ public final class GroupInviteLinkUrl {
|
|||||||
private final GroupLinkPassword password;
|
private final GroupLinkPassword password;
|
||||||
private final String url;
|
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)
|
public static @Nullable GroupInviteLinkUrl fromUrl(@NonNull String urlString)
|
||||||
throws InvalidGroupLinkException, UnknownGroupLinkVersionException
|
throws InvalidGroupLinkException, UnknownGroupLinkVersionException
|
||||||
{
|
{
|
||||||
URL url;
|
URL url = getGroupUrl(urlString);
|
||||||
try {
|
|
||||||
url = new URL(urlString);
|
if (url == null) {
|
||||||
} catch (MalformedURLException e) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!GROUP_URL_HOST.equalsIgnoreCase(url.getHost())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!"/".equals(url.getPath()) && url.getPath().length() > 0) {
|
if (!"/".equals(url.getPath()) && url.getPath().length() > 0) {
|
||||||
throw new InvalidGroupLinkException("No path was expected in url");
|
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) {
|
private GroupInviteLinkUrl(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) {
|
||||||
this.groupMasterKey = groupMasterKey;
|
this.groupMasterKey = groupMasterKey;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
package org.thoughtcrime.securesms.jobs;
|
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.signal.zkgroup.groups.GroupSecretParams;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
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);
|
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());
|
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();
|
attachment.deleteOnExit();
|
||||||
|
|
||||||
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
|
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
|
||||||
byte[] encryptedData;
|
byte[] encryptedData;
|
||||||
|
|
||||||
try (FileInputStream inputStream = receiver.retrieveGroupsV2ProfileAvatar(cdnKey, attachment, AVATAR_DOWNLOAD_FAIL_SAFE_MAX_SIZE)) {
|
try (FileInputStream inputStream = receiver.retrieveGroupsV2ProfileAvatar(cdnKey, attachment, AVATAR_DOWNLOAD_FAIL_SAFE_MAX_SIZE)) {
|
||||||
|
|
||||||
encryptedData = new byte[(int) attachment.length()];
|
encryptedData = new byte[(int) attachment.length()];
|
||||||
|
|
||||||
Util.readFully(inputStream, encryptedData);
|
Util.readFully(inputStream, encryptedData);
|
||||||
|
|
||||||
GroupsV2Operations operations = ApplicationDependencies.getGroupsV2Operations();
|
GroupsV2Operations operations = ApplicationDependencies.getGroupsV2Operations();
|
||||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(record.get().requireV2GroupProperties().getGroupMasterKey());
|
|
||||||
GroupsV2Operations.GroupOperations groupOperations = operations.forGroup(groupSecretParams);
|
GroupsV2Operations.GroupOperations groupOperations = operations.forGroup(groupSecretParams);
|
||||||
byte[] decryptedAvatar = groupOperations.decryptAvatar(encryptedData);
|
|
||||||
|
|
||||||
AvatarHelper.setAvatar(context, record.get().getRecipientId(), decryptedAvatar != null ? new ByteArrayInputStream(decryptedAvatar) : null);
|
return groupOperations.decryptAvatar(encryptedData);
|
||||||
database.onAvatarUpdated(groupId, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (NonSuccessfulResponseCodeException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (attachment != null && attachment.exists())
|
if (attachment.exists())
|
||||||
if (!attachment.delete()) {
|
if (!attachment.delete()) {
|
||||||
Log.w(TAG, "Unable to delete temp avatar file");
|
Log.w(TAG, "Unable to delete temp avatar file");
|
||||||
}
|
}
|
||||||
|
@ -7,13 +7,23 @@ import android.net.Uri;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.util.Consumer;
|
||||||
|
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
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.Attachment;
|
||||||
import org.thoughtcrime.securesms.attachments.UriAttachment;
|
import org.thoughtcrime.securesms.attachments.UriAttachment;
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
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.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.linkpreview.LinkPreviewUtil.OpenGraph;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
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.CompositeRequestController;
|
||||||
import org.thoughtcrime.securesms.net.RequestController;
|
import org.thoughtcrime.securesms.net.RequestController;
|
||||||
import org.thoughtcrime.securesms.net.UserAgentInterceptor;
|
import org.thoughtcrime.securesms.net.UserAgentInterceptor;
|
||||||
|
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
|
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
|
||||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||||
|
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||||
import org.thoughtcrime.securesms.util.ByteUnit;
|
import org.thoughtcrime.securesms.util.ByteUnit;
|
||||||
import org.thoughtcrime.securesms.util.Hex;
|
import org.thoughtcrime.securesms.util.Hex;
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
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.Pair;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
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;
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest.StickerInfo;
|
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest.StickerInfo;
|
||||||
|
|
||||||
@ -65,12 +79,15 @@ public class LinkPreviewRepository {
|
|||||||
.build();
|
.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();
|
CompositeRequestController compositeController = new CompositeRequestController();
|
||||||
|
|
||||||
if (!LinkPreviewUtil.isValidPreviewUrl(url)) {
|
if (!LinkPreviewUtil.isValidPreviewUrl(url)) {
|
||||||
Log.w(TAG, "Tried to get a link preview for a non-whitelisted domain.");
|
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;
|
return compositeController;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,23 +95,25 @@ public class LinkPreviewRepository {
|
|||||||
|
|
||||||
if (StickerUrl.isValidShareLink(url)) {
|
if (StickerUrl.isValidShareLink(url)) {
|
||||||
metadataController = fetchStickerPackLinkPreview(context, url, callback);
|
metadataController = fetchStickerPackLinkPreview(context, url, callback);
|
||||||
|
} else if (GroupInviteLinkUrl.isGroupLink(url)) {
|
||||||
|
metadataController = fetchGroupLinkPreview(context, url, callback);
|
||||||
} else {
|
} else {
|
||||||
metadataController = fetchMetadata(url, metadata -> {
|
metadataController = fetchMetadata(url, metadata -> {
|
||||||
if (metadata.isEmpty()) {
|
if (metadata.isEmpty()) {
|
||||||
callback.onComplete(Optional.absent());
|
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata.getImageUrl().isPresent()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RequestController imageController = fetchThumbnail(metadata.getImageUrl().get(), attachment -> {
|
RequestController imageController = fetchThumbnail(metadata.getImageUrl().get(), attachment -> {
|
||||||
if (!metadata.getTitle().isPresent() && !attachment.isPresent()) {
|
if (!metadata.getTitle().isPresent() && !attachment.isPresent()) {
|
||||||
callback.onComplete(Optional.absent());
|
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||||
} else {
|
} 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;
|
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 call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build());
|
||||||
|
|
||||||
call.enqueue(new okhttp3.Callback() {
|
call.enqueue(new okhttp3.Callback() {
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
||||||
Log.w(TAG, "Request failed.", e);
|
Log.w(TAG, "Request failed.", e);
|
||||||
callback.onComplete(Metadata.empty());
|
callback.accept(Metadata.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
||||||
if (!response.isSuccessful()) {
|
if (!response.isSuccessful()) {
|
||||||
Log.w(TAG, "Non-successful response. Code: " + response.code());
|
Log.w(TAG, "Non-successful response. Code: " + response.code());
|
||||||
callback.onComplete(Metadata.empty());
|
callback.accept(Metadata.empty());
|
||||||
return;
|
return;
|
||||||
} else if (response.body() == null) {
|
} else if (response.body() == null) {
|
||||||
Log.w(TAG, "No response body.");
|
Log.w(TAG, "No response body.");
|
||||||
callback.onComplete(Metadata.empty());
|
callback.accept(Metadata.empty());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,14 +157,14 @@ public class LinkPreviewRepository {
|
|||||||
imageUrl = Optional.absent();
|
imageUrl = Optional.absent();
|
||||||
}
|
}
|
||||||
|
|
||||||
callback.onComplete(new Metadata(title, imageUrl));
|
callback.accept(new Metadata(title, imageUrl));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return new CallRequestController(call);
|
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());
|
Call call = client.newCall(new Request.Builder().url(imageUrl).build());
|
||||||
CallRequestController controller = new CallRequestController(call);
|
CallRequestController controller = new CallRequestController(call);
|
||||||
|
|
||||||
@ -163,11 +182,13 @@ public class LinkPreviewRepository {
|
|||||||
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
|
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
|
||||||
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.JPEG, MediaUtil.IMAGE_JPEG);
|
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) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, "Exception during link preview image retrieval.", e);
|
Log.w(TAG, "Exception during link preview image retrieval.", e);
|
||||||
controller.cancel();
|
controller.cancel();
|
||||||
callback.onComplete(Optional.absent());
|
callback.accept(Optional.absent());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -176,7 +197,7 @@ public class LinkPreviewRepository {
|
|||||||
|
|
||||||
private static RequestController fetchStickerPackLinkPreview(@NonNull Context context,
|
private static RequestController fetchStickerPackLinkPreview(@NonNull Context context,
|
||||||
@NonNull String packUrl,
|
@NonNull String packUrl,
|
||||||
@NonNull Callback<Optional<LinkPreview>> callback)
|
@NonNull Callback callback)
|
||||||
{
|
{
|
||||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||||
try {
|
try {
|
||||||
@ -204,19 +225,86 @@ public class LinkPreviewRepository {
|
|||||||
|
|
||||||
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
|
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 {
|
} else {
|
||||||
callback.onComplete(Optional.absent());
|
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||||
}
|
}
|
||||||
} catch (IOException | InvalidMessageException | ExecutionException | InterruptedException e) {
|
} catch (IOException | InvalidMessageException | ExecutionException | InterruptedException e) {
|
||||||
Log.w(TAG, "Failed to fetch sticker pack link preview.");
|
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.");
|
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,
|
private static Optional<Attachment> bitmapToAttachment(@Nullable Bitmap bitmap,
|
||||||
@NonNull Bitmap.CompressFormat format,
|
@NonNull Bitmap.CompressFormat format,
|
||||||
@NonNull String contentType)
|
@NonNull String contentType)
|
||||||
@ -277,7 +365,14 @@ public class LinkPreviewRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Callback<T> {
|
interface Callback {
|
||||||
void onComplete(@NonNull T result);
|
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;
|
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.LiveData;
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
import androidx.lifecycle.ViewModel;
|
import androidx.lifecycle.ViewModel;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
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.net.RequestController;
|
||||||
import org.thoughtcrime.securesms.util.Debouncer;
|
import org.thoughtcrime.securesms.util.Debouncer;
|
||||||
@ -86,21 +88,30 @@ public class LinkPreviewViewModel extends ViewModel {
|
|||||||
linkPreviewState.setValue(LinkPreviewState.forLoading());
|
linkPreviewState.setValue(LinkPreviewState.forLoading());
|
||||||
|
|
||||||
activeUrl = link.get().getUrl();
|
activeUrl = link.get().getUrl();
|
||||||
activeRequest = repository.getLinkPreview(context, link.get().getUrl(), lp -> {
|
activeRequest = repository.getLinkPreview(context, link.get().getUrl(), new LinkPreviewRepository.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(@NonNull LinkPreview linkPreview) {
|
||||||
Util.runOnMain(() -> {
|
Util.runOnMain(() -> {
|
||||||
if (!userCanceled) {
|
if (!userCanceled) {
|
||||||
if (lp.isPresent()) {
|
if (activeUrl != null && activeUrl.equals(linkPreview.getUrl())) {
|
||||||
if (activeUrl != null && activeUrl.equals(lp.get().getUrl())) {
|
linkPreviewState.setValue(LinkPreviewState.forPreview(linkPreview));
|
||||||
linkPreviewState.setValue(LinkPreviewState.forPreview(lp.get()));
|
|
||||||
} else {
|
} else {
|
||||||
linkPreviewState.setValue(LinkPreviewState.forNoLinks());
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -160,27 +171,33 @@ public class LinkPreviewViewModel extends ViewModel {
|
|||||||
private final boolean isLoading;
|
private final boolean isLoading;
|
||||||
private final boolean hasLinks;
|
private final boolean hasLinks;
|
||||||
private final Optional<LinkPreview> linkPreview;
|
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.isLoading = isLoading;
|
||||||
this.hasLinks = hasLinks;
|
this.hasLinks = hasLinks;
|
||||||
this.linkPreview = linkPreview;
|
this.linkPreview = linkPreview;
|
||||||
|
this.error = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LinkPreviewState forLoading() {
|
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) {
|
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() {
|
private static LinkPreviewState forLinksWithNoPreview(@NonNull LinkPreviewRepository.Error error) {
|
||||||
return new LinkPreviewState(false, true, Optional.absent());
|
return new LinkPreviewState(false, true, Optional.absent(), error);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LinkPreviewState forNoLinks() {
|
private static LinkPreviewState forNoLinks() {
|
||||||
return new LinkPreviewState(false, false, Optional.absent());
|
return new LinkPreviewState(false, false, Optional.absent(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isLoading() {
|
public boolean isLoading() {
|
||||||
@ -195,6 +212,10 @@ public class LinkPreviewViewModel extends ViewModel {
|
|||||||
return linkPreview;
|
return linkPreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public @Nullable LinkPreviewRepository.Error getError() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
boolean hasContent() {
|
boolean hasContent() {
|
||||||
return isLoading || hasLinks;
|
return isLoading || hasLinks;
|
||||||
}
|
}
|
||||||
|
@ -58,10 +58,11 @@ import static org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBann
|
|||||||
|
|
||||||
public class Recipient {
|
public class Recipient {
|
||||||
|
|
||||||
|
private static final String TAG = Log.tag(Recipient.class);
|
||||||
|
|
||||||
public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails(), true);
|
public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails(), true);
|
||||||
|
|
||||||
private static final FallbackPhotoProvider DEFAULT_FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider();
|
public static final FallbackPhotoProvider DEFAULT_FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider();
|
||||||
private static final String TAG = Log.tag(Recipient.class);
|
|
||||||
|
|
||||||
private final RecipientId id;
|
private final RecipientId id;
|
||||||
private final boolean resolving;
|
private final boolean resolving;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.util;
|
package org.thoughtcrime.securesms.util;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
@ -67,13 +68,22 @@ public final class AvatarUtil {
|
|||||||
public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) {
|
public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) {
|
||||||
Context context = target.getContext();
|
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
|
@WorkerThread
|
||||||
public static IconCompat getIconForNotification(@NonNull Context context, @NonNull Recipient recipient) {
|
public static IconCompat getIconForNotification(@NonNull Context context, @NonNull Recipient recipient) {
|
||||||
try {
|
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) {
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -88,10 +98,17 @@ public final class AvatarUtil {
|
|||||||
.diskCacheStrategy(DiskCacheStrategy.ALL);
|
.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) {
|
private static <T> GlideRequest<T> request(@NonNull GlideRequest<T> glideRequest, @NonNull Context context, @NonNull Recipient recipient) {
|
||||||
return glideRequest.load(new ProfileContactPhoto(recipient, recipient.getProfileAvatar()))
|
return glideRequest.load(new ProfileContactPhoto(recipient, recipient.getProfileAvatar()))
|
||||||
.error(getFallback(context, recipient))
|
.error(getFallback(context, recipient))
|
||||||
.circleCrop()
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.ALL);
|
.diskCacheStrategy(DiskCacheStrategy.ALL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,7 +24,9 @@ import org.thoughtcrime.securesms.R;
|
|||||||
import org.thoughtcrime.securesms.WebRtcCallActivity;
|
import org.thoughtcrime.securesms.WebRtcCallActivity;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.groups.GroupId;
|
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.ui.invitesandrequests.joining.GroupJoinUpdateRequiredBottomSheetDialogFragment;
|
||||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
@ -194,17 +196,20 @@ public class CommunicationActions {
|
|||||||
{
|
{
|
||||||
GroupId.V2 groupId = GroupId.v2(groupInviteLinkUrl.getGroupMasterKey());
|
GroupId.V2 groupId = GroupId.v2(groupInviteLinkUrl.getGroupMasterKey());
|
||||||
|
|
||||||
SimpleTask.run(SignalExecutors.BOUNDED, () ->
|
SimpleTask.run(SignalExecutors.BOUNDED, () -> {
|
||||||
DatabaseFactory.getGroupDatabase(activity)
|
GroupDatabase.GroupRecord group = DatabaseFactory.getGroupDatabase(activity)
|
||||||
.getGroup(groupId)
|
.getGroup(groupId)
|
||||||
.transform(groupRecord -> Recipient.resolved(groupRecord.getRecipientId()))
|
.orNull();
|
||||||
.orNull(),
|
|
||||||
|
return group != null && group.isActive() ? Recipient.resolved(group.getRecipientId())
|
||||||
|
: null;
|
||||||
|
},
|
||||||
recipient -> {
|
recipient -> {
|
||||||
if (recipient != null) {
|
if (recipient != null) {
|
||||||
CommunicationActions.startConversation(activity, recipient, null);
|
CommunicationActions.startConversation(activity, recipient, null);
|
||||||
Toast.makeText(activity, R.string.GroupJoinBottomSheetDialogFragment_you_are_already_a_member, Toast.LENGTH_SHORT).show();
|
Toast.makeText(activity, R.string.GroupJoinBottomSheetDialogFragment_you_are_already_a_member, Toast.LENGTH_SHORT).show();
|
||||||
} else {
|
} else {
|
||||||
GroupJoinUpdateRequiredBottomSheetDialogFragment.show(activity.getSupportFragmentManager());
|
GroupJoinBottomSheetDialogFragment.show(activity.getSupportFragmentManager(), groupInviteLinkUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -30,15 +30,16 @@ import android.os.Build.VERSION_CODES;
|
|||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.provider.Telephony;
|
import android.provider.Telephony;
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.annotation.RequiresPermission;
|
|
||||||
import android.telephony.TelephonyManager;
|
import android.telephony.TelephonyManager;
|
||||||
import android.text.Spannable;
|
import android.text.Spannable;
|
||||||
import android.text.SpannableString;
|
import android.text.SpannableString;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.text.style.StyleSpan;
|
import android.text.style.StyleSpan;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.RequiresPermission;
|
||||||
|
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
import com.google.android.mms.pdu_alt.CharacterSets;
|
import com.google.android.mms.pdu_alt.CharacterSets;
|
||||||
import com.google.android.mms.pdu_alt.EncodedStringValue;
|
import com.google.android.mms.pdu_alt.EncodedStringValue;
|
||||||
@ -339,6 +340,10 @@ public class Util {
|
|||||||
return Optional.fromNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null);
|
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
|
@SafeVarargs
|
||||||
public static @NonNull <T> T firstNonNull(T ... ts) {
|
public static @NonNull <T> T firstNonNull(T ... ts) {
|
||||||
for (T t : ts) {
|
for (T t : ts) {
|
||||||
|
109
app/src/main/res/layout/group_join_bottom_sheet.xml
Normal file
109
app/src/main/res/layout/group_join_bottom_sheet.xml
Normal file
@ -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:id="@+id/linkpreview_no_preview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
android:paddingTop="12dp"
|
android:paddingTop="12dp"
|
||||||
android:paddingBottom="12dp"
|
android:paddingBottom="12dp"
|
||||||
android:text="@string/LinkPreviewView_no_link_preview_available"
|
|
||||||
android:gravity="center"
|
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/linkpreview_divider"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toTopOf="@id/linkpreview_divider"/>
|
tools:text="@string/LinkPreviewView_no_link_preview_available" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
@ -492,6 +492,7 @@
|
|||||||
|
|
||||||
<!-- LinkPreviewView -->
|
<!-- LinkPreviewView -->
|
||||||
<string name="LinkPreviewView_no_link_preview_available">No link preview available</string>
|
<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 -->
|
<!-- PendingMembersActivity -->
|
||||||
<string name="PendingMemberInvitesActivity_pending_group_invites">Pending group invites</string>
|
<string name="PendingMemberInvitesActivity_pending_group_invites">Pending group invites</string>
|
||||||
@ -647,6 +648,16 @@
|
|||||||
|
|
||||||
<!-- GroupJoinBottomSheetDialogFragment -->
|
<!-- GroupJoinBottomSheetDialogFragment -->
|
||||||
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">You are already a member</string>
|
<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 -->
|
<!-- GroupJoinUpdateRequiredBottomSheetDialogFragment -->
|
||||||
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal_to_use_group_links">Update Signal to use group links</string>
|
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal_to_use_group_links">Update Signal to use group links</string>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user