Group link preview and info display bottom sheet.

This commit is contained in:
Alan Evans
2020-08-18 16:28:37 -03:00
committed by Greyson Parrelli
parent 477bb45df7
commit 09d167c16d
22 changed files with 799 additions and 106 deletions

View File

@@ -6,16 +6,20 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.groups.UuidCiphertext;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
import java.io.IOException;
import java.util.Collection;
@@ -254,6 +258,20 @@ public final class GroupManager {
}
}
/**
* Use to get a group's details direct from server bypassing the database.
* <p>
* Useful when you don't yet have the group in the database locally.
*/
@WorkerThread
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@NonNull GroupLinkPassword groupLinkPassword)
throws IOException, VerificationFailedException, GroupLinkNotActiveException
{
return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword);
}
public static class GroupActionResult {
private final Recipient groupRecipient;
private final long threadId;

View File

@@ -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());

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
enum FetchGroupDetailsError {
GroupLinkNotActive,
NetworkError
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;