Group link preview and info display bottom sheet.

master
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

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

View File

@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
@ -236,9 +237,9 @@ public class InputPanel extends LinearLayout
this.linkPreview.setLoading();
}
public void setLinkPreviewNoPreview() {
public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setNoPreview();
this.linkPreview.setNoPreview(customError);
}
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional<LinkPreview> preview) {

View File

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

View File

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

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;

View File

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

View File

@ -7,13 +7,23 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.UriAttachment;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil.OpenGraph;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
@ -21,9 +31,12 @@ import org.thoughtcrime.securesms.net.CallRequestController;
import org.thoughtcrime.securesms.net.CompositeRequestController;
import org.thoughtcrime.securesms.net.RequestController;
import org.thoughtcrime.securesms.net.UserAgentInterceptor;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
import org.thoughtcrime.securesms.stickers.StickerUrl;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.Hex;
import org.thoughtcrime.securesms.util.MediaUtil;
@ -33,6 +46,7 @@ import org.whispersystems.libsignal.InvalidMessageException;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest;
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest.StickerInfo;
@ -65,12 +79,15 @@ public class LinkPreviewRepository {
.build();
}
RequestController getLinkPreview(@NonNull Context context, @NonNull String url, @NonNull Callback<Optional<LinkPreview>> callback) {
@Nullable RequestController getLinkPreview(@NonNull Context context,
@NonNull String url,
@NonNull Callback callback)
{
CompositeRequestController compositeController = new CompositeRequestController();
if (!LinkPreviewUtil.isValidPreviewUrl(url)) {
Log.w(TAG, "Tried to get a link preview for a non-whitelisted domain.");
callback.onComplete(Optional.absent());
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
return compositeController;
}
@ -78,23 +95,25 @@ public class LinkPreviewRepository {
if (StickerUrl.isValidShareLink(url)) {
metadataController = fetchStickerPackLinkPreview(context, url, callback);
} else if (GroupInviteLinkUrl.isGroupLink(url)) {
metadataController = fetchGroupLinkPreview(context, url, callback);
} else {
metadataController = fetchMetadata(url, metadata -> {
if (metadata.isEmpty()) {
callback.onComplete(Optional.absent());
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
return;
}
if (!metadata.getImageUrl().isPresent()) {
callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().get(), Optional.absent())));
callback.onSuccess(new LinkPreview(url, metadata.getTitle().get(), Optional.absent()));
return;
}
RequestController imageController = fetchThumbnail(metadata.getImageUrl().get(), attachment -> {
if (!metadata.getTitle().isPresent() && !attachment.isPresent()) {
callback.onComplete(Optional.absent());
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
} else {
callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().or(""), attachment)));
callback.onSuccess(new LinkPreview(url, metadata.getTitle().or(""), attachment));
}
});
@ -106,25 +125,25 @@ public class LinkPreviewRepository {
return compositeController;
}
private @NonNull RequestController fetchMetadata(@NonNull String url, Callback<Metadata> callback) {
private @NonNull RequestController fetchMetadata(@NonNull String url, Consumer<Metadata> callback) {
Call call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build());
call.enqueue(new okhttp3.Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
Log.w(TAG, "Request failed.", e);
callback.onComplete(Metadata.empty());
callback.accept(Metadata.empty());
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
if (!response.isSuccessful()) {
Log.w(TAG, "Non-successful response. Code: " + response.code());
callback.onComplete(Metadata.empty());
callback.accept(Metadata.empty());
return;
} else if (response.body() == null) {
Log.w(TAG, "No response body.");
callback.onComplete(Metadata.empty());
callback.accept(Metadata.empty());
return;
}
@ -138,14 +157,14 @@ public class LinkPreviewRepository {
imageUrl = Optional.absent();
}
callback.onComplete(new Metadata(title, imageUrl));
callback.accept(new Metadata(title, imageUrl));
}
});
return new CallRequestController(call);
}
private @NonNull RequestController fetchThumbnail(@NonNull String imageUrl, @NonNull Callback<Optional<Attachment>> callback) {
private @NonNull RequestController fetchThumbnail(@NonNull String imageUrl, @NonNull Consumer<Optional<Attachment>> callback) {
Call call = client.newCall(new Request.Builder().url(imageUrl).build());
CallRequestController controller = new CallRequestController(call);
@ -163,11 +182,13 @@ public class LinkPreviewRepository {
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.JPEG, MediaUtil.IMAGE_JPEG);
callback.onComplete(thumbnail);
if (bitmap != null) bitmap.recycle();
callback.accept(thumbnail);
} catch (IOException e) {
Log.w(TAG, "Exception during link preview image retrieval.", e);
controller.cancel();
callback.onComplete(Optional.absent());
callback.accept(Optional.absent());
}
});
@ -176,7 +197,7 @@ public class LinkPreviewRepository {
private static RequestController fetchStickerPackLinkPreview(@NonNull Context context,
@NonNull String packUrl,
@NonNull Callback<Optional<LinkPreview>> callback)
@NonNull Callback callback)
{
SignalExecutors.UNBOUNDED.execute(() -> {
try {
@ -204,19 +225,86 @@ public class LinkPreviewRepository {
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
callback.onComplete(Optional.of(new LinkPreview(packUrl, title, thumbnail)));
if (bitmap != null) bitmap.recycle();
callback.onSuccess(new LinkPreview(packUrl, title, thumbnail));
} else {
callback.onComplete(Optional.absent());
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
}
} catch (IOException | InvalidMessageException | ExecutionException | InterruptedException e) {
Log.w(TAG, "Failed to fetch sticker pack link preview.");
callback.onComplete(Optional.absent());
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
}
});
return () -> Log.i(TAG, "Cancelled sticker pack link preview fetch -- no effect.");
}
private static RequestController fetchGroupLinkPreview(@NonNull Context context,
@NonNull String groupUrl,
@NonNull Callback callback)
{
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUrl(groupUrl);
if (groupInviteLinkUrl == null) {
throw new AssertionError();
}
GroupMasterKey groupMasterKey = groupInviteLinkUrl.getGroupMasterKey();
GroupId.V2 groupId = GroupId.v2(groupMasterKey);
Optional<GroupDatabase.GroupRecord> group = DatabaseFactory.getGroupDatabase(context)
.getGroup(groupId);
if (group.isPresent()) {
Log.i(TAG, "Creating preview for locally available group");
GroupDatabase.GroupRecord groupRecord = group.get();
String title = groupRecord.getTitle();
Optional<Attachment> thumbnail = Optional.absent();
if (AvatarHelper.hasAvatar(context, groupRecord.getRecipientId())) {
Recipient recipient = Recipient.resolved(groupRecord.getRecipientId());
Bitmap bitmap = AvatarUtil.loadIconBitmapSquare(context, recipient, 512, 512);
thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
if (bitmap != null) bitmap.recycle();
}
callback.onSuccess(new LinkPreview(groupUrl, title, thumbnail));
} else {
Log.i(TAG, "Group is not locally available for preview generation, fetching from server");
DecryptedGroupJoinInfo joinInfo = GroupManager.getGroupJoinInfoFromServer(context, groupMasterKey, groupInviteLinkUrl.getPassword());
Optional<Attachment> thumbnail = Optional.absent();
byte[] avatarBytes = AvatarGroupsV2DownloadJob.downloadGroupAvatarBytes(context, groupMasterKey, joinInfo.getAvatar());
if (avatarBytes != null) {
Bitmap bitmap = BitmapFactory.decodeByteArray(avatarBytes, 0, avatarBytes.length);
thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.WEBP, MediaUtil.IMAGE_WEBP);
if (bitmap != null) bitmap.recycle();
}
callback.onSuccess(new LinkPreview(groupUrl, joinInfo.getTitle(), thumbnail));
}
} catch (ExecutionException | InterruptedException | IOException | VerificationFailedException e) {
Log.w(TAG, "Failed to fetch group link preview.", e);
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
} catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
Log.w(TAG, "Bad group link.", e);
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
} catch (GroupLinkNotActiveException e) {
Log.w(TAG, "Group link not active.", e);
callback.onError(Error.GROUP_LINK_INACTIVE);
}
});
return () -> Log.i(TAG, "Cancelled group link preview fetch -- no effect.");
}
private static Optional<Attachment> bitmapToAttachment(@Nullable Bitmap bitmap,
@NonNull Bitmap.CompressFormat format,
@NonNull String contentType)
@ -277,7 +365,14 @@ public class LinkPreviewRepository {
}
}
interface Callback<T> {
void onComplete(@NonNull T result);
interface Callback {
void onSuccess(@NonNull LinkPreview linkPreview);
void onError(@NonNull Error error);
}
public enum Error {
PREVIEW_NOT_AVAILABLE,
GROUP_LINK_INACTIVE
}
}

View File

@ -1,12 +1,14 @@
package org.thoughtcrime.securesms.linkpreview;
import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import android.content.Context;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import org.thoughtcrime.securesms.net.RequestController;
import org.thoughtcrime.securesms.util.Debouncer;
@ -86,21 +88,30 @@ public class LinkPreviewViewModel extends ViewModel {
linkPreviewState.setValue(LinkPreviewState.forLoading());
activeUrl = link.get().getUrl();
activeRequest = repository.getLinkPreview(context, link.get().getUrl(), lp -> {
Util.runOnMain(() -> {
if (!userCanceled) {
if (lp.isPresent()) {
if (activeUrl != null && activeUrl.equals(lp.get().getUrl())) {
linkPreviewState.setValue(LinkPreviewState.forPreview(lp.get()));
} else {
linkPreviewState.setValue(LinkPreviewState.forNoLinks());
activeRequest = repository.getLinkPreview(context, link.get().getUrl(), new LinkPreviewRepository.Callback() {
@Override
public void onSuccess(@NonNull LinkPreview linkPreview) {
Util.runOnMain(() -> {
if (!userCanceled) {
if (activeUrl != null && activeUrl.equals(linkPreview.getUrl())) {
linkPreviewState.setValue(LinkPreviewState.forPreview(linkPreview));
} else {
linkPreviewState.setValue(LinkPreviewState.forNoLinks());
}
}
} else {
linkPreviewState.setValue(LinkPreviewState.forLinksWithNoPreview());
}
activeRequest = null;
});
}
activeRequest = null;
});
@Override
public void onError(@NonNull LinkPreviewRepository.Error error) {
Util.runOnMain(() -> {
if (!userCanceled) {
linkPreviewState.setValue(LinkPreviewState.forLinksWithNoPreview(error));
}
activeRequest = null;
});
}
});
});
}
@ -157,30 +168,36 @@ public class LinkPreviewViewModel extends ViewModel {
}
public static class LinkPreviewState {
private final boolean isLoading;
private final boolean hasLinks;
private final Optional<LinkPreview> linkPreview;
private final boolean isLoading;
private final boolean hasLinks;
private final Optional<LinkPreview> linkPreview;
private final LinkPreviewRepository.Error error;
private LinkPreviewState(boolean isLoading, boolean hasLinks, Optional<LinkPreview> linkPreview) {
private LinkPreviewState(boolean isLoading,
boolean hasLinks,
Optional<LinkPreview> linkPreview,
@Nullable LinkPreviewRepository.Error error)
{
this.isLoading = isLoading;
this.hasLinks = hasLinks;
this.linkPreview = linkPreview;
this.error = error;
}
private static LinkPreviewState forLoading() {
return new LinkPreviewState(true, false, Optional.absent());
return new LinkPreviewState(true, false, Optional.absent(), null);
}
private static LinkPreviewState forPreview(@NonNull LinkPreview linkPreview) {
return new LinkPreviewState(false, true, Optional.of(linkPreview));
return new LinkPreviewState(false, true, Optional.of(linkPreview), null);
}
private static LinkPreviewState forLinksWithNoPreview() {
return new LinkPreviewState(false, true, Optional.absent());
private static LinkPreviewState forLinksWithNoPreview(@NonNull LinkPreviewRepository.Error error) {
return new LinkPreviewState(false, true, Optional.absent(), error);
}
private static LinkPreviewState forNoLinks() {
return new LinkPreviewState(false, false, Optional.absent());
return new LinkPreviewState(false, false, Optional.absent(), null);
}
public boolean isLoading() {
@ -195,6 +212,10 @@ public class LinkPreviewViewModel extends ViewModel {
return linkPreview;
}
public @Nullable LinkPreviewRepository.Error getError() {
return error;
}
boolean hasContent() {
return isLoading || hasLinks;
}

View File

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

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.View;
@ -67,13 +68,22 @@ public final class AvatarUtil {
public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) {
Context context = target.getContext();
request(GlideApp.with(context).asDrawable(), context, recipient).into(target);
requestCircle(GlideApp.with(context).asDrawable(), context, recipient).into(target);
}
public static Bitmap loadIconBitmapSquare(@NonNull Context context,
@NonNull Recipient recipient,
int width,
int height)
throws ExecutionException, InterruptedException
{
return requestSquare(GlideApp.with(context).asBitmap(), context, recipient).submit(width, height).get();
}
@WorkerThread
public static IconCompat getIconForNotification(@NonNull Context context, @NonNull Recipient recipient) {
try {
return IconCompat.createWithBitmap(request(GlideApp.with(context).asBitmap(), context, recipient).submit().get());
return IconCompat.createWithBitmap(requestCircle(GlideApp.with(context).asBitmap(), context, recipient).submit().get());
} catch (ExecutionException | InterruptedException e) {
return null;
}
@ -88,10 +98,17 @@ public final class AvatarUtil {
.diskCacheStrategy(DiskCacheStrategy.ALL);
}
private static <T> GlideRequest<T> requestCircle(@NonNull GlideRequest<T> glideRequest, @NonNull Context context, @NonNull Recipient recipient) {
return request(glideRequest, context, recipient).circleCrop();
}
private static <T> GlideRequest<T> requestSquare(@NonNull GlideRequest<T> glideRequest, @NonNull Context context, @NonNull Recipient recipient) {
return request(glideRequest, context, recipient).centerCrop();
}
private static <T> GlideRequest<T> request(@NonNull GlideRequest<T> glideRequest, @NonNull Context context, @NonNull Recipient recipient) {
return glideRequest.load(new ProfileContactPhoto(recipient, recipient.getProfileAvatar()))
.error(getFallback(context, recipient))
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.ALL);
}

View File

@ -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) {

View File

@ -30,15 +30,16 @@ import android.os.Build.VERSION_CODES;
import android.os.Handler;
import android.os.Looper;
import android.provider.Telephony;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresPermission;
import android.telephony.TelephonyManager;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresPermission;
import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.CharacterSets;
import com.google.android.mms.pdu_alt.EncodedStringValue;
@ -339,6 +340,10 @@ public class Util {
return Optional.fromNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null);
}
public static @NonNull <T> T firstNonNull(@Nullable T optional, @NonNull T fallback) {
return optional != null ? optional : fallback;
}
@SafeVarargs
public static @NonNull <T> T firstNonNull(T ... ts) {
for (T t : ts) {

View 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>

View File

@ -102,13 +102,13 @@
android:id="@+id/linkpreview_no_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:text="@string/LinkPreviewView_no_link_preview_available"
android:gravity="center"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/linkpreview_divider"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/linkpreview_divider"/>
tools:text="@string/LinkPreviewView_no_link_preview_available" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -492,6 +492,7 @@
<!-- LinkPreviewView -->
<string name="LinkPreviewView_no_link_preview_available">No link preview available</string>
<string name="LinkPreviewView_this_group_link_is_not_active">This group link is not active</string>
<!-- PendingMembersActivity -->
<string name="PendingMemberInvitesActivity_pending_group_invites">Pending group invites</string>
@ -647,6 +648,16 @@
<!-- GroupJoinBottomSheetDialogFragment -->
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">You are already a member</string>
<string name="GroupJoinBottomSheetDialogFragment_join">Join</string>
<string name="GroupJoinBottomSheetDialogFragment_request_to_join">Request to join</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">This group link is not active</string>
<string name="GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later">Unable to get group information, please try again later</string>
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Do you want to join this group and share your name and photo with its members?</string>
<string name="GroupJoinBottomSheetDialogFragment_admin_approval_needed">An admin of this group must approve your request before you can join this group. When you request to join, your name and photo will be shared with its members.</string>
<plurals name="GroupJoinBottomSheetDialogFragment_group_dot_d_members">
<item quantity="one">Group · %1$d member</item>
<item quantity="other">Group · %1$d members</item>
</plurals>
<!-- GroupJoinUpdateRequiredBottomSheetDialogFragment -->
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal_to_use_group_links">Update Signal to use group links</string>