diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index 363b49655..baccb1b04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index ddbddb219..97d9ec904 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -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 preview) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java index 441e20b7c..8537ea44e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java @@ -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(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 6431a797a..78727a5c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 3983e1bbf..141550b2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -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. + *

+ * 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 65502203e..a2082746e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java new file mode 100644 index 000000000..9ee2281fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +enum FetchGroupDetailsError { + GroupLinkNotActive, + NetworkError +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java new file mode 100644 index 000000000..25940cfd7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java new file mode 100644 index 000000000..bef15ba0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java new file mode 100644 index 000000000..83fee2d46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java @@ -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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java new file mode 100644 index 000000000..f3102a079 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java @@ -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 = new MutableLiveData<>(); + private final MutableLiveData errors = new SingleLiveEvent<>(); + private final MutableLiveData 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 getGroupDetails() { + return groupDetails; + } + + LiveData isBusy() { + return busy; + } + + LiveData 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 create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new GroupJoinViewModel(new GroupJoinRepository(context.getApplicationContext(), groupInviteLinkUrl)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java index ce9e1ddcb..f6848e062 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java index 44cc7e736..c6c27d949 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java @@ -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"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java index 2ca339fb0..e799ef8ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -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> 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 callback) { + private @NonNull RequestController fetchMetadata(@NonNull String url, Consumer 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> callback) { + private @NonNull RequestController fetchThumbnail(@NonNull String imageUrl, @NonNull Consumer> 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 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> callback) + @NonNull Callback callback) { SignalExecutors.UNBOUNDED.execute(() -> { try { @@ -204,19 +225,86 @@ public class LinkPreviewRepository { Optional 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 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 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 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 bitmapToAttachment(@Nullable Bitmap bitmap, @NonNull Bitmap.CompressFormat format, @NonNull String contentType) @@ -277,7 +365,14 @@ public class LinkPreviewRepository { } } - interface Callback { - 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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java index 2ea159635..fe34eebd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java @@ -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; + private final boolean isLoading; + private final boolean hasLinks; + private final Optional linkPreview; + private final LinkPreviewRepository.Error error; - private LinkPreviewState(boolean isLoading, boolean hasLinks, Optional linkPreview) { + private LinkPreviewState(boolean isLoading, + boolean hasLinks, + Optional 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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 330121ca2..779bf045d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 86428e774..747828cfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -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 GlideRequest requestCircle(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { + return request(glideRequest, context, recipient).circleCrop(); + } + + private static GlideRequest requestSquare(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { + return request(glideRequest, context, recipient).centerCrop(); + } + private static GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { return glideRequest.load(new ProfileContactPhoto(recipient, recipient.getProfileAvatar())) .error(getFallback(context, recipient)) - .circleCrop() .diskCacheStrategy(DiskCacheStrategy.ALL); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 937e76d7e..1f9576d85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index 72bffd016..583e0c9d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -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 firstNonNull(@Nullable T optional, @NonNull T fallback) { + return optional != null ? optional : fallback; + } + @SafeVarargs public static @NonNull T firstNonNull(T ... ts) { for (T t : ts) { diff --git a/app/src/main/res/layout/group_join_bottom_sheet.xml b/app/src/main/res/layout/group_join_bottom_sheet.xml new file mode 100644 index 000000000..e75482d3b --- /dev/null +++ b/app/src/main/res/layout/group_join_bottom_sheet.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + +