From bfed03b7b58c01584380a04029d3dcbc14fcc124 Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Wed, 26 Aug 2020 15:59:34 -0300 Subject: [PATCH] Manage group links behind feature flag. --- app/src/main/AndroidManifest.xml | 4 + .../thoughtcrime/securesms/BaseActivity.java | 11 +- .../securesms/groups/GroupAccessControl.java | 3 +- .../securesms/groups/GroupManager.java | 49 +++++ .../securesms/groups/GroupManagerV2.java | 49 ++++- .../securesms/groups/LiveGroup.java | 105 +++++++-- .../groups/ui/AdminActionsListener.java | 4 + .../securesms/groups/ui/GroupMemberEntry.java | 41 ++++ .../groups/ui/GroupMemberListAdapter.java | 61 +++++- ...gePendingAndRequestingMembersActivity.java | 108 ++++++++++ .../InviteRevokeConfirmationDialog.java | 2 +- .../PendingMemberInvitesFragment.java | 32 ++- .../PendingMemberInvitesRepository.java} | 8 +- .../PendingMemberInvitesViewModel.java | 14 +- .../requesting/RequestConfirmationDialog.java | 62 ++++++ .../RequestingMemberInvitesViewModel.java | 113 ++++++++++ .../RequestingMemberRepository.java | 61 ++++++ .../requesting/RequestingMembersFragment.java | 102 +++++++++ .../ui/managegroup/ManageGroupFragment.java | 74 +++++-- .../ui/managegroup/ManageGroupViewModel.java | 13 ++ .../PendingMemberInvitesActivity.java | 12 ++ .../groups/v2/GroupLinkUrlAndStatus.java | 33 +++ .../securesms/mms/MessageGroupContext.java | 16 +- .../org/thoughtcrime/securesms/qr/QrCode.java | 8 +- .../GroupLinkBottomSheetDialogFragment.java | 100 +++++++++ .../ShareableGroupLinkDialogFragment.java | 112 ++++++++++ .../ShareableGroupLinkRepository.java | 115 ++++++++++ .../ShareableGroupLinkViewModel.java | 111 ++++++++++ .../securesms/util/FeatureFlags.java | 42 +++- .../main/res/drawable/circle_ultramarine.xml | 5 + .../main/res/drawable/ic_check_28_tinted.xml | 12 ++ .../drawable/ic_copy_outline_24_tinted.xml | 10 + .../res/drawable/ic_copy_solid_24_tinted.xml | 10 + .../main/res/drawable/ic_deny_28_tinted.xml | 12 ++ .../main/res/drawable/ic_qrcode_24_tinted.xml | 36 ++++ .../main/res/drawable/ic_reset_24_tinted.xml | 9 + .../drawable/ic_share_outline_24_tinted.xml | 9 + .../res/drawable/ic_share_solid_24_tinted.xml | 9 + .../custom_notifications_dialog_fragment.xml | 11 +- .../layout/group_link_share_bottom_sheet.xml | 61 ++++++ .../main/res/layout/group_manage_fragment.xml | 92 +++++++- ...pending_and_requesting_member_activity.xml | 37 ++++ .../group_pending_member_invites_fragment.xml | 4 +- .../group_recipient_requesting_list_item.xml | 106 +++++++++ .../group_requesting_member_fragment.xml | 76 +++++++ .../shareable_group_link_dialog_fragment.xml | 203 ++++++++++++++++++ app/src/main/res/values/attrs.xml | 5 + app/src/main/res/values/strings.xml | 47 ++++ app/src/main/res/values/themes.xml | 10 + .../api/groupsv2/DecryptedGroupUtil.java | 18 ++ .../internal/push/PushServiceSocket.java | 10 +- 51 files changed, 2177 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/ManagePendingAndRequestingMembersActivity.java rename app/src/main/java/org/thoughtcrime/securesms/groups/ui/{pendingmemberinvites => invitesandrequests/invited}/InviteRevokeConfirmationDialog.java (96%) rename app/src/main/java/org/thoughtcrime/securesms/groups/ui/{pendingmemberinvites => invitesandrequests/invited}/PendingMemberInvitesFragment.java (73%) rename app/src/main/java/org/thoughtcrime/securesms/groups/ui/{pendingmemberinvites/PendingMemberRepository.java => invitesandrequests/invited/PendingMemberInvitesRepository.java} (95%) rename app/src/main/java/org/thoughtcrime/securesms/groups/ui/{pendingmemberinvites => invitesandrequests/invited}/PendingMemberInvitesViewModel.java (89%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestConfirmationDialog.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberInvitesViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMembersFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkUrlAndStatus.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkDialogFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java create mode 100644 app/src/main/res/drawable/circle_ultramarine.xml create mode 100644 app/src/main/res/drawable/ic_check_28_tinted.xml create mode 100644 app/src/main/res/drawable/ic_copy_outline_24_tinted.xml create mode 100644 app/src/main/res/drawable/ic_copy_solid_24_tinted.xml create mode 100644 app/src/main/res/drawable/ic_deny_28_tinted.xml create mode 100644 app/src/main/res/drawable/ic_qrcode_24_tinted.xml create mode 100644 app/src/main/res/drawable/ic_reset_24_tinted.xml create mode 100644 app/src/main/res/drawable/ic_share_outline_24_tinted.xml create mode 100644 app/src/main/res/drawable/ic_share_solid_24_tinted.xml create mode 100644 app/src/main/res/layout/group_link_share_bottom_sheet.xml create mode 100644 app/src/main/res/layout/group_pending_and_requesting_member_activity.xml create mode 100644 app/src/main/res/layout/group_recipient_requesting_list_item.xml create mode 100644 app/src/main/res/layout/group_requesting_member_fragment.xml create mode 100644 app/src/main/res/layout/shareable_group_link_dialog_fragment.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ddebc729d2..e64c955fa3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -268,6 +268,10 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" android:theme="@style/Theme.Signal.DayNight.NoActionBar" /> + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java index 4cac62e27d..6ab7bf04e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java @@ -8,6 +8,7 @@ import android.os.Build.VERSION_CODES; import android.os.Bundle; import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityOptionsCompat; import androidx.appcompat.app.AppCompatActivity; @@ -19,6 +20,8 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageActivityHelper; import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; +import java.util.Objects; + /** * Base class for all activities. The vast majority of activities shouldn't extend this directly. * Instead, they should extend {@link PassphraseRequiredActivity} so they're protected by @@ -72,9 +75,9 @@ public abstract class BaseActivity extends AppCompatActivity { ActivityCompat.startActivity(this, intent, bundle); } - @TargetApi(VERSION_CODES.LOLLIPOP) + @TargetApi(21) protected void setStatusBarColor(int color) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (Build.VERSION.SDK_INT >= 21) { getWindow().setStatusBarColor(color); } } @@ -87,4 +90,8 @@ public abstract class BaseActivity extends AppCompatActivity { private void logEvent(@NonNull String event) { Log.d(TAG, "[" + Log.tag(getClass()) + "] " + event); } + + protected final @NonNull ActionBar requireSupportActionBar() { + return Objects.requireNonNull(getSupportActionBar()); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java index 803d7d653f..7a0d7252d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java @@ -6,7 +6,8 @@ import org.thoughtcrime.securesms.R; public enum GroupAccessControl { ALL_MEMBERS(R.string.GroupManagement_access_level_all_members), - ONLY_ADMINS(R.string.GroupManagement_access_level_only_admins); + ONLY_ADMINS(R.string.GroupManagement_access_level_only_admins), + NO_ONE(R.string.GroupManagement_access_level_no_one); private final @StringRes int string; 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 d1a7113818..748a6a8005 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -247,6 +247,49 @@ public final class GroupManager { } } + @WorkerThread + public static void cycleGroupLinkPassword(@NonNull Context context, + @NonNull GroupId.V2 groupId) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.cycleGroupLinkPassword(); + } + } + + @WorkerThread + public static void setGroupLinkEnabledState(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull GroupLinkState state) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.setJoinByGroupLinkState(state); + } + } + + @WorkerThread + public static void approveRequests(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull Collection recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.approveRequests(recipientIds); + } + } + + @WorkerThread + public static void denyRequests(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull Collection recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + { + try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { + editor.denyRequests(recipientIds); + } + } + @WorkerThread public static @NonNull GroupActionResult addMembers(@NonNull Context context, @NonNull GroupId.Push groupId, @@ -339,4 +382,10 @@ public final class GroupManager { return invitedMembers; } } + + public enum GroupLinkState { + DISABLED, + ENABLED, + ENABLED_WITH_APPROVAL + } } 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 1a03c7dd51..d2a5a12fc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -292,6 +292,17 @@ final class GroupManagerV2 { return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts))); } + @WorkerThread + @NonNull GroupManager.GroupActionResult approveRequests(@NonNull Collection recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + Set uuids = Stream.of(recipientIds) + .map(r -> Recipient.resolved(r).getUuid().get()) + .collect(Collectors.toSet()); + + return commitChangeWithConflictResolution(groupOperations.createApproveGroupJoinRequest(uuids)); + } + @WorkerThread @NonNull GroupManager.GroupActionResult denyRequests(@NonNull Collection recipientIds) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException @@ -402,6 +413,40 @@ final class GroupManagerV2 { return commitChangeWithConflictResolution(groupOperations.createAcceptInviteChange(groupCandidate.getProfileKeyCredential().get())); } + @WorkerThread + public GroupManager.GroupActionResult cycleGroupLinkPassword() + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + return commitChangeWithConflictResolution(groupOperations.createModifyGroupLinkPasswordChange(GroupLinkPassword.createNew().serialize())); + } + + @WorkerThread + public GroupManager.GroupActionResult setJoinByGroupLinkState(@NonNull GroupManager.GroupLinkState state) + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + AccessControl.AccessRequired access; + + switch (state) { + case DISABLED : access = AccessControl.AccessRequired.UNSATISFIABLE; break; + case ENABLED : access = AccessControl.AccessRequired.ANY; break; + case ENABLED_WITH_APPROVAL: access = AccessControl.AccessRequired.ADMINISTRATOR; break; + default: throw new AssertionError(); + } + + GroupChange.Actions.Builder change = groupOperations.createChangeJoinByLinkRights(access); + + if (state != GroupManager.GroupLinkState.DISABLED) { + DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + + if (group.getInviteLinkPassword().isEmpty()) { + Log.d(TAG, "First time enabling group links for group and password empty, generating"); + change = groupOperations.createModifyGroupLinkPasswordAndRightsChange(GroupLinkPassword.createNew().serialize(), access); + } + } + + return commitChangeWithConflictResolution(change); + } + private @NonNull GroupManager.GroupActionResult commitChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change) throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException { @@ -480,7 +525,7 @@ final class GroupManagerV2 { return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, recipientAndThread.threadId, newMembersCount, newPendingMembers); } - private GroupChange commitToServer(GroupChange.Actions change) + private @NonNull GroupChange commitToServer(@NonNull GroupChange.Actions change) throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException { try { @@ -948,6 +993,8 @@ final class GroupManagerV2 { return AccessControl.AccessRequired.MEMBER; case ONLY_ADMINS: return AccessControl.AccessRequired.ADMINISTRATOR; + case NO_ONE: + return AccessControl.AccessRequired.UNSATISFIABLE; default: throw new AssertionError(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index ed95478dda..05152ee4ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -12,17 +12,25 @@ import androidx.lifecycle.Transformations; import com.annimon.stream.ComparatorCompat; import com.annimon.stream.Stream; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; +import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.signalservice.api.util.UuidUtil; +import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -37,30 +45,77 @@ public final class LiveGroup { .thenComparing(HAS_DISPLAY_NAME) .thenComparing(ALPHABETICAL); - private final GroupDatabase groupDatabase; - private final LiveData recipient; - private final LiveData groupRecord; - private final LiveData> fullMembers; + private final GroupDatabase groupDatabase; + private final LiveData recipient; + private final LiveData groupRecord; + private final LiveData> fullMembers; + private final LiveData> requestingMembers; + private final LiveData groupLink; public LiveGroup(@NonNull GroupId groupId) { Context context = ApplicationDependencies.getApplication(); MutableLiveData liveRecipient = new MutableLiveData<>(); - this.groupDatabase = DatabaseFactory.getGroupDatabase(context); - this.recipient = Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData); - this.groupRecord = LiveDataUtil.filterNotNull(LiveDataUtil.mapAsync(recipient, groupRecipient -> groupDatabase.getGroup(groupRecipient.getId()).orNull())); - this.fullMembers = LiveDataUtil.mapAsync(groupRecord, - g -> Stream.of(g.getMembers()) - .map(m -> { - Recipient recipient = Recipient.resolved(m); - return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient)); - }) - .sorted(MEMBER_ORDER) - .toList()); + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + this.recipient = Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData); + this.groupRecord = LiveDataUtil.filterNotNull(LiveDataUtil.mapAsync(recipient, groupRecipient -> groupDatabase.getGroup(groupRecipient.getId()).orNull())); + this.fullMembers = mapToFullMembers(this.groupRecord); + this.requestingMembers = mapToRequestingMembers(this.groupRecord); + + if (groupId.isV2()) { + LiveData v2Properties = Transformations.map(this.groupRecord, GroupDatabase.GroupRecord::requireV2GroupProperties); + this.groupLink = Transformations.map(v2Properties, g -> { + DecryptedGroup group = g.getDecryptedGroup(); + AccessControl.AccessRequired addFromInviteLink = group.getAccessControl().getAddFromInviteLink(); + + if (group.getInviteLinkPassword().isEmpty()) { + return GroupLinkUrlAndStatus.NONE; + } + + boolean enabled = addFromInviteLink == AccessControl.AccessRequired.ANY || addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR; + boolean adminApproval = addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR; + String url = GroupInviteLinkUrl.forGroup(g.getGroupMasterKey(), group) + .getUrl(); + + return new GroupLinkUrlAndStatus(enabled, adminApproval, url); + }); + } else { + this.groupLink = new MutableLiveData<>(GroupLinkUrlAndStatus.NONE); + } SignalExecutors.BOUNDED.execute(() -> liveRecipient.postValue(Recipient.externalGroup(context, groupId).live())); } + protected static LiveData> mapToFullMembers(@NonNull LiveData groupRecord) { + return LiveDataUtil.mapAsync(groupRecord, + g -> Stream.of(g.getMembers()) + .map(m -> { + Recipient recipient = Recipient.resolved(m); + return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient)); + }) + .sorted(MEMBER_ORDER) + .toList()); + } + + protected static LiveData> mapToRequestingMembers(@NonNull LiveData groupRecord) { + return LiveDataUtil.mapAsync(groupRecord, + g -> { + if (!g.isV2Group()) { + return Collections.emptyList(); + } + + boolean selfAdmin = g.isAdmin(Recipient.self()); + List requestingMembersList = g.requireV2GroupProperties().getDecryptedGroup().getRequestingMembersList(); + + return Stream.of(requestingMembersList) + .map(requestingMember -> { + Recipient recipient = Recipient.externalPush(ApplicationDependencies.getApplication(), UuidUtil.fromByteString(requestingMember.getUuid()), null, false); + return new GroupMemberEntry.RequestingMember(recipient, selfAdmin); + }) + .toList(); + }); + } + public LiveData getTitle() { return LiveDataUtil.combineLatest(groupRecord, recipient, (groupRecord, recipient) -> { String title = groupRecord.getTitle(); @@ -91,6 +146,17 @@ public final class LiveGroup { return Transformations.map(groupRecord, g -> g.isV2Group() ? g.requireV2GroupProperties().getDecryptedGroup().getPendingMembersCount() : 0); } + public LiveData getPendingAndRequestingMemberCount() { + return Transformations.map(groupRecord, g -> { + if (g.isV2Group()) { + DecryptedGroup decryptedGroup = g.requireV2GroupProperties().getDecryptedGroup(); + + return decryptedGroup.getPendingMembersCount() + decryptedGroup.getRequestingMembersCount(); + } + return 0; + }); + } + public LiveData getMembershipAdditionAccessControl() { return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getMembershipAdditionAccessControl); } @@ -110,6 +176,10 @@ public final class LiveGroup { return fullMembers; } + public LiveData> getRequestingMembers() { + return requestingMembers; + } + public LiveData getExpireMessages() { return Transformations.map(recipient, Recipient::getExpireMessages); } @@ -153,7 +223,12 @@ public final class LiveGroup { switch (rights) { case ALL_MEMBERS: return memberLevel.isInGroup(); case ONLY_ADMINS: return memberLevel == GroupDatabase.MemberLevel.ADMINISTRATOR; + case NO_ONE : return false; default: throw new AssertionError(); } } + + public LiveData getGroupLink() { + return groupLink; + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java index 83ad77edae..2e3c29a65e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java @@ -7,4 +7,8 @@ public interface AdminActionsListener { void onRevokeInvite(@NonNull GroupMemberEntry.PendingMember pendingMember); void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers); + + void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember); + + void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java index ccd32ce2ba..697fc8633f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java @@ -222,4 +222,45 @@ public abstract class GroupMemberEntry { return hash + (cancellable ? 1 : 0); } } + + public final static class RequestingMember extends GroupMemberEntry { + private final Recipient requester; + private final boolean approvableDeniable; + + public RequestingMember(@NonNull Recipient requester, boolean approvableDeniable) { + this.requester = requester; + this.approvableDeniable = approvableDeniable; + } + + public Recipient getRequester() { + return requester; + } + + public boolean isApprovableDeniable() { + return approvableDeniable; + } + + @Override + boolean sameId(@NonNull GroupMemberEntry newItem) { + if (getClass() != newItem.getClass()) return false; + + return requester.getId().equals(((GroupMemberEntry.PendingMember) newItem).invitee.getId()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof RequestingMember)) return false; + + RequestingMember other = (RequestingMember) obj; + return other.requester.equals(requester) && + other.approvableDeniable == approvableDeniable; + } + + @Override + public int hashCode() { + int hash = requester.hashCode(); + hash *= 31; + return hash + (approvableDeniable ? 1 : 0); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java index 9add247eff..6bdb94e3d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java @@ -30,6 +30,7 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter data = new ArrayList<>(); private final Set selection = new HashSet<>(); @@ -101,6 +102,14 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter adminActionsListener.onApproveRequest(requestingMember)); + denyRequest .setOnClickListener(v -> adminActionsListener.onDenyRequest (requestingMember)); + } else { + approveRequest.setVisibility(View.GONE); + denyRequest .setVisibility(View.GONE); + approveRequest.setOnClickListener(null); + denyRequest .setOnClickListener(null); + } + + bindRecipient(requestingMember.getRequester()); + bindRecipientClick(requestingMember.getRequester()); + } + } + private final class SelectionChangeListener { void onSelectionChange(int position, boolean isChecked) { if (selectable) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/ManagePendingAndRequestingMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/ManagePendingAndRequestingMembersActivity.java new file mode 100644 index 0000000000..c701f49430 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/ManagePendingAndRequestingMembersActivity.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited.PendingMemberInvitesFragment; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting.RequestingMembersFragment; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class ManagePendingAndRequestingMembersActivity extends PassphraseRequiredActivity { + + private static final String GROUP_ID = "GROUP_ID"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static Intent newIntent(@NonNull Context context, @NonNull GroupId.V2 groupId) { + Intent intent = new Intent(context, ManagePendingAndRequestingMembersActivity.class); + intent.putExtra(GROUP_ID, groupId.toString()); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.group_pending_and_requesting_member_activity); + + if (savedInstanceState == null) { + GroupId.V2 groupId = GroupId.parseOrThrow(getIntent().getStringExtra(GROUP_ID)).requireV2(); + + ViewPager2 viewPager = findViewById(R.id.pending_and_requesting_pager); + TabLayout tabLayout = findViewById(R.id.pending_and_requesting_tabs); + + viewPager.setAdapter(new ViewPagerAdapter(this, groupId)); + + new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> { + switch (position) { + case 0 : tab.setText(R.string.PendingMembersActivity_requests); break; + case 1 : tab.setText(R.string.PendingMembersActivity_invites); break; + default: throw new AssertionError(); + } + } + ).attach(); + } + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + requireSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + private static class ViewPagerAdapter extends FragmentStateAdapter { + + private final GroupId.V2 groupId; + + public ViewPagerAdapter(@NonNull FragmentActivity fragmentActivity, + @NonNull GroupId.V2 groupId) + { + super(fragmentActivity); + this.groupId = groupId; + } + + @Override + public @NonNull Fragment createFragment(int position) { + switch (position) { + case 0 : return RequestingMembersFragment.newInstance(groupId); + case 1 : return PendingMemberInvitesFragment.newInstance(groupId); + default: throw new AssertionError(); + } + } + + @Override + public int getItemCount() { + return 2; + } + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/InviteRevokeConfirmationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/InviteRevokeConfirmationDialog.java similarity index 96% rename from app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/InviteRevokeConfirmationDialog.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/InviteRevokeConfirmationDialog.java index f8f0c4c90f..c219af073c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/InviteRevokeConfirmationDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/InviteRevokeConfirmationDialog.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; import android.content.Context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesFragment.java similarity index 73% rename from app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesFragment.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesFragment.java index 3380f1e440..ee38bfed8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesFragment.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; import android.os.Bundle; import android.view.LayoutInflater; @@ -15,6 +15,8 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.ui.AdminActionsListener; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.util.BottomSheetUtil; import java.util.Objects; @@ -47,6 +49,10 @@ public class PendingMemberInvitesFragment extends Fragment { youInvitedEmptyState = view.findViewById(R.id.no_pending_from_you); othersInvitedEmptyState = view.findViewById(R.id.no_pending_from_others); + youInvited.setRecipientClickListener(recipient -> + RecipientBottomSheetDialogFragment.create(recipient.getId(), null) + .show(requireActivity().getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)); + youInvited.setAdminActionsListener(new AdminActionsListener() { @Override @@ -56,7 +62,17 @@ public class PendingMemberInvitesFragment extends Fragment { @Override public void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { - throw new AssertionError(); + throw new UnsupportedOperationException(); + } + + @Override + public void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); + } + + @Override + public void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); } }); @@ -64,13 +80,23 @@ public class PendingMemberInvitesFragment extends Fragment { @Override public void onRevokeInvite(@NonNull GroupMemberEntry.PendingMember pendingMember) { - throw new AssertionError(); + throw new UnsupportedOperationException(); } @Override public void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { viewModel.revokeInvitesFor(pendingMembers); } + + @Override + public void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); + } + + @Override + public void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + throw new UnsupportedOperationException(); + } }); return view; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java similarity index 95% rename from app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java index bc78ddf965..c8e6aeae11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; import android.content.Context; @@ -33,15 +33,15 @@ import java.util.concurrent.Executor; /** * Repository for modifying the pending members on a single group. */ -final class PendingMemberRepository { +final class PendingMemberInvitesRepository { - private static final String TAG = Log.tag(PendingMemberRepository.class); + private static final String TAG = Log.tag(PendingMemberInvitesRepository.class); private final Context context; private final GroupId.V2 groupId; private final Executor executor; - PendingMemberRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { + PendingMemberInvitesRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { this.context = context.getApplicationContext(); this.executor = SignalExecutors.BOUNDED; this.groupId = groupId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesViewModel.java similarity index 89% rename from app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesViewModel.java index eb0aaf3921..2ce0294692 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesViewModel.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited; import android.content.Context; import android.widget.Toast; @@ -23,12 +23,12 @@ import java.util.List; public class PendingMemberInvitesViewModel extends ViewModel { private final Context context; - private final PendingMemberRepository pendingMemberRepository; + private final PendingMemberInvitesRepository pendingMemberRepository; private final DefaultValueLiveData> whoYouInvited = new DefaultValueLiveData<>(Collections.emptyList()); private final DefaultValueLiveData> whoOthersInvited = new DefaultValueLiveData<>(Collections.emptyList()); private PendingMemberInvitesViewModel(@NonNull Context context, - @NonNull PendingMemberRepository pendingMemberRepository) + @NonNull PendingMemberInvitesRepository pendingMemberRepository) { this.context = context; this.pendingMemberRepository = pendingMemberRepository; @@ -49,17 +49,17 @@ public class PendingMemberInvitesViewModel extends ViewModel { whoOthersInvited.postValue(byOthers); } - private void setMembers(PendingMemberRepository.InviteeResult inviteeResult) { + private void setMembers(PendingMemberInvitesRepository.InviteeResult inviteeResult) { List byMe = new ArrayList<>(inviteeResult.getByMe().size()); List byOthers = new ArrayList<>(inviteeResult.getByOthers().size()); - for (PendingMemberRepository.SinglePendingMemberInvitedByYou pendingMember : inviteeResult.getByMe()) { + for (PendingMemberInvitesRepository.SinglePendingMemberInvitedByYou pendingMember : inviteeResult.getByMe()) { byMe.add(new GroupMemberEntry.PendingMember(pendingMember.getInvitee(), pendingMember.getInviteeCipherText(), inviteeResult.isCanRevokeInvites())); } - for (PendingMemberRepository.MultiplePendingMembersInvitedByAnother pendingMembers : inviteeResult.getByOthers()) { + for (PendingMemberInvitesRepository.MultiplePendingMembersInvitedByAnother pendingMembers : inviteeResult.getByOthers()) { byOthers.add(new GroupMemberEntry.UnknownPendingMemberCount(pendingMembers.getInviter(), pendingMembers.getUuidCipherTexts(), inviteeResult.isCanRevokeInvites())); @@ -148,7 +148,7 @@ public class PendingMemberInvitesViewModel extends ViewModel { @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection unchecked - return (T) new PendingMemberInvitesViewModel(context, new PendingMemberRepository(context.getApplicationContext(), groupId)); + return (T) new PendingMemberInvitesViewModel(context, new PendingMemberInvitesRepository(context.getApplicationContext(), groupId)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestConfirmationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestConfirmationDialog.java new file mode 100644 index 0000000000..d6329faf74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestConfirmationDialog.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +final class RequestConfirmationDialog { + + private RequestConfirmationDialog() { + } + + /** + * Confirms that you want to approve or deny a request to join the group depending on + * {@param approve}. + */ + static AlertDialog show(@NonNull Context context, + @NonNull Recipient requester, + boolean approve, + @NonNull Runnable onApproveOrDeny) + { + if (approve) { + return showRequestApproveConfirmationDialog(context, requester, onApproveOrDeny); + } else { + return showRequestDenyConfirmationDialog(context, requester, onApproveOrDeny); + } + } + + /** + * Confirms that you want to approve a request to join the group. + */ + private static AlertDialog showRequestApproveConfirmationDialog(@NonNull Context context, + @NonNull Recipient requester, + @NonNull Runnable onApprove) + { + return new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.RequestConfirmationDialog_add_s_to_the_group, + requester.getDisplayName(context))) + .setPositiveButton(R.string.RequestConfirmationDialog_add, (dialog, which) -> onApprove.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + /** + * Confirms that you want to deny a request to join the group. + */ + private static AlertDialog showRequestDenyConfirmationDialog(@NonNull Context context, + @NonNull Recipient requester, + @NonNull Runnable onDeny) + { + return new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.RequestConfirmationDialog_deny_request_from_s, + requester.getDisplayName(context))) + .setPositiveButton(R.string.RequestConfirmationDialog_deny, (dialog, which) -> onDeny.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberInvitesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberInvitesViewModel.java new file mode 100644 index 0000000000..0bcd62835c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberInvitesViewModel.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.content.Context; + +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 org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +public class RequestingMemberInvitesViewModel extends ViewModel { + + private final Context context; + private final RequestingMemberRepository requestingMemberRepository; + private final MutableLiveData toasts; + private final LiveData> requesting; + + private RequestingMemberInvitesViewModel(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull RequestingMemberRepository requestingMemberRepository) + { + this.context = context; + this.requestingMemberRepository = requestingMemberRepository; + this.requesting = new LiveGroup(groupId).getRequestingMembers(); + this.toasts = new SingleLiveEvent<>(); + } + + LiveData> getRequesting() { + return requesting; + } + + LiveData getToasts() { + return toasts; + } + + void approveRequestFor(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + approveOrDeny(requestingMember, true); + } + + void denyRequestFor(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + approveOrDeny(requestingMember, false); + } + + private void approveOrDeny(@NonNull GroupMemberEntry.RequestingMember requestingMember, boolean approve) { + RequestConfirmationDialog.show(context, requestingMember.getRequester(), approve, () -> { + Set memberAsSet = Collections.singleton(requestingMember.getRequester().getId()); + + if (approve) { + requestingMember.setBusy(true); + requestingMemberRepository.approveRequests(memberAsSet, new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(R.string.RequestingMembersFragment_added_s, requestingMember.getRequester().getDisplayName(context))); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error))); + } + }); + } else { + requestingMember.setBusy(true); + requestingMemberRepository.denyRequests(memberAsSet, new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(R.string.RequestingMembersFragment_denied_s, requestingMember.getRequester().getDisplayName(context))); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + requestingMember.setBusy(false); + toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error))); + } + }); + } + }); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + private final GroupId.V2 groupId; + + public Factory(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new RequestingMemberInvitesViewModel(context, groupId, new RequestingMemberRepository(context.getApplicationContext(), groupId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberRepository.java new file mode 100644 index 0000000000..613ef12142 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMemberRepository.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.groups.GroupChangeException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.io.IOException; +import java.util.Collection; + +/** + * Repository for modifying the requesting members on a single group. + */ +final class RequestingMemberRepository { + + private static final String TAG = Log.tag(RequestingMemberRepository.class); + + private final Context context; + private final GroupId.V2 groupId; + + RequestingMemberRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context.getApplicationContext(); + this.groupId = groupId; + } + + void approveRequests(@NonNull Collection recipientIds, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.approveRequests(context, groupId, recipientIds); + callback.onComplete(null); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void denyRequests(@NonNull Collection recipientIds, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.denyRequests(context, groupId, recipientIds); + callback.onComplete(null); + } catch (GroupChangeException | IOException e) { + Log.w(TAG, e); + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMembersFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMembersFragment.java new file mode 100644 index 0000000000..04c2ab229d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/requesting/RequestingMembersFragment.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.requesting; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.AdminActionsListener; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.util.BottomSheetUtil; + +import java.util.Objects; + +/** + * Lists and allows approval/denial of people requesting access to the group. + */ +public class RequestingMembersFragment extends Fragment { + + private static final String GROUP_ID = "GROUP_ID"; + + private RequestingMemberInvitesViewModel viewModel; + private GroupMemberListView requestingMembers; + private View noRequestingMessage; + private View requestingExplanation; + + public static RequestingMembersFragment newInstance(@NonNull GroupId.V2 groupId) { + RequestingMembersFragment fragment = new RequestingMembersFragment(); + Bundle args = new Bundle(); + + args.putString(GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.group_requesting_member_fragment, container, false); + + requestingMembers = view.findViewById(R.id.requesting_members); + noRequestingMessage = view.findViewById(R.id.no_requesting); + requestingExplanation = view.findViewById(R.id.requesting_members_explain); + + requestingMembers.setRecipientClickListener(recipient -> + RecipientBottomSheetDialogFragment.create(recipient.getId(), null) + .show(requireActivity().getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)); + + requestingMembers.setAdminActionsListener(new AdminActionsListener() { + + @Override + public void onRevokeInvite(@NonNull GroupMemberEntry.PendingMember pendingMember) { + throw new UnsupportedOperationException(); + } + + @Override + public void onRevokeAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + throw new UnsupportedOperationException(); + } + + @Override + public void onApproveRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + viewModel.approveRequestFor(requestingMember); + } + + @Override + public void onDenyRequest(@NonNull GroupMemberEntry.RequestingMember requestingMember) { + viewModel.denyRequestFor(requestingMember); + } + }); + + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + GroupId.V2 groupId = GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(GROUP_ID))).requireV2(); + + RequestingMemberInvitesViewModel.Factory factory = new RequestingMemberInvitesViewModel.Factory(requireContext(), groupId); + + viewModel = ViewModelProviders.of(requireActivity(), factory).get(RequestingMemberInvitesViewModel.class); + + viewModel.getRequesting().observe(getViewLifecycleOwner(), requesting -> { + requestingMembers.setMembers(requesting); + noRequestingMessage.setVisibility(requesting.isEmpty() ? View.VISIBLE: View.GONE); + requestingExplanation.setVisibility(requesting.isEmpty() ? View.GONE : View.VISIBLE); + }); + + viewModel.getToasts().observe(getViewLifecycleOwner(), toast -> Toast.makeText(requireContext(), toast, Toast.LENGTH_SHORT).show()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 8be92a6ed0..df174b5c65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupRightsDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment; @@ -50,6 +51,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; +import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.ShareableGroupLinkDialogFragment; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; @@ -64,13 +66,16 @@ public class ManageGroupFragment extends LoggingFragment { private static final String TAG = Log.tag(ManageGroupFragment.class); - private static final int RETURN_FROM_MEDIA = 33114; - private static final int PICK_CONTACT = 61341; + private static final int RETURN_FROM_MEDIA = 33114; + private static final int PICK_CONTACT = 61341; + public static final String DIALOG_TAG = "DIALOG"; private ManageGroupViewModel viewModel; private GroupMemberListView groupMemberList; private View pendingMembersRow; private TextView pendingMembersCount; + private View pendingAndRequestingRow; + private TextView pendingAndRequestingCount; private Toolbar toolbar; private TextView groupName; private LearnMoreTextView groupV1Indicator; @@ -81,6 +86,7 @@ public class ManageGroupFragment extends LoggingFragment { private View groupMediaCard; private View accessControlCard; private View pendingMembersCard; + private View groupLinkCard; private ManageGroupViewModel.CursorFactory cursorFactory; private View sharedMediaRow; private View editGroupAccessRow; @@ -103,6 +109,8 @@ public class ManageGroupFragment extends LoggingFragment { private View mentionsRow; private TextView mentionsValue; private View toggleAllMembers; + private View groupLinkRow; + private TextView groupLinkButton; private final Recipient.FallbackPhotoProvider fallbackPhotoProvider = new Recipient.FallbackPhotoProvider() { @Override @@ -137,10 +145,13 @@ public class ManageGroupFragment extends LoggingFragment { groupMemberList = view.findViewById(R.id.group_members); pendingMembersRow = view.findViewById(R.id.pending_members_row); pendingMembersCount = view.findViewById(R.id.pending_members_count); + pendingAndRequestingRow = view.findViewById(R.id.pending_and_requesting_members_row); + pendingAndRequestingCount = view.findViewById(R.id.pending_and_requesting_members_count); threadPhotoRailView = view.findViewById(R.id.recent_photos); groupMediaCard = view.findViewById(R.id.group_media_card); accessControlCard = view.findViewById(R.id.group_access_control_card); pendingMembersCard = view.findViewById(R.id.group_pending_card); + groupLinkCard = view.findViewById(R.id.group_link_card); sharedMediaRow = view.findViewById(R.id.shared_media_row); editGroupAccessRow = view.findViewById(R.id.edit_group_access_row); editGroupAccessValue = view.findViewById(R.id.edit_group_access_value); @@ -162,6 +173,8 @@ public class ManageGroupFragment extends LoggingFragment { mentionsRow = view.findViewById(R.id.group_mentions_row); mentionsValue = view.findViewById(R.id.group_mentions_value); toggleAllMembers = view.findViewById(R.id.toggle_all_members); + groupLinkRow = view.findViewById(R.id.group_link_row); + groupLinkButton = view.findViewById(R.id.group_link_button); groupV1Indicator.setOnLinkClickListener(v -> GroupsLearnMoreBottomSheetDialogFragment.show(requireFragmentManager())); groupV1Indicator.setLearnMoreVisible(true); @@ -193,18 +206,34 @@ public class ManageGroupFragment extends LoggingFragment { } }); - viewModel.getPendingMemberCount().observe(getViewLifecycleOwner(), - pendingInviteCount -> { - pendingMembersRow.setOnClickListener(v -> { - FragmentActivity activity = requireActivity(); - activity.startActivity(PendingMemberInvitesActivity.newIntent(activity, groupId.requireV2())); + if (FeatureFlags.groupsV2manageGroupLinks()) { + viewModel.getPendingAndRequestingCount().observe(getViewLifecycleOwner(), + pendingAndRequestingCount -> { + pendingAndRequestingRow.setOnClickListener(v -> { + FragmentActivity activity = requireActivity(); + activity.startActivity(ManagePendingAndRequestingMembersActivity.newIntent(activity, groupId.requireV2())); + }); + if (pendingAndRequestingCount == 0) { + this.pendingAndRequestingCount.setVisibility(View.GONE); + } else { + this.pendingAndRequestingCount.setText(String.format(Locale.getDefault(), "%d", pendingAndRequestingCount)); + this.pendingAndRequestingCount.setVisibility(View.VISIBLE); + } }); - if (pendingInviteCount == 0) { - pendingMembersCount.setText(R.string.ManageGroupActivity_none); - } else { - pendingMembersCount.setText(getResources().getQuantityString(R.plurals.ManageGroupActivity_invited, pendingInviteCount, pendingInviteCount)); - } - }); + } else { + viewModel.getPendingMemberCount().observe(getViewLifecycleOwner(), + pendingInviteCount -> { + pendingMembersRow.setOnClickListener(v -> { + FragmentActivity activity = requireActivity(); + activity.startActivity(PendingMemberInvitesActivity.newIntent(activity, groupId.requireV2())); + }); + if (pendingInviteCount == 0) { + pendingMembersCount.setText(R.string.ManageGroupActivity_none); + } else { + pendingMembersCount.setText(getResources().getQuantityString(R.plurals.ManageGroupActivity_invited, pendingInviteCount, pendingInviteCount)); + } + }); + } avatar.setFallbackPhotoProvider(fallbackPhotoProvider); @@ -230,9 +259,15 @@ public class ManageGroupFragment extends LoggingFragment { AvatarPreviewActivity.createTransitionBundle(activity, avatar)); }); customNotificationsRow.setOnClickListener(v -> CustomNotificationsDialogFragment.create(groupRecipient.getId()) - .show(requireFragmentManager(), "CUSTOM_NOTIFICATIONS")); + .show(requireFragmentManager(), DIALOG_TAG)); }); + if (groupId.isV2()) { + groupLinkRow.setOnClickListener(v -> ShareableGroupLinkDialogFragment.create(groupId.requireV2()) + .show(requireFragmentManager(), DIALOG_TAG)); + viewModel.getGroupLinkOn().observe(getViewLifecycleOwner(), linkEnabled -> groupLinkButton.setText(booleanToOnOff(linkEnabled))); + } + viewModel.getGroupViewState().observe(getViewLifecycleOwner(), vs -> { if (vs == null) return; sharedMediaRow.setOnClickListener(v -> startActivity(MediaOverviewActivity.forThread(context, vs.getThreadId()))); @@ -245,7 +280,8 @@ public class ManageGroupFragment extends LoggingFragment { ViewCompat.getLayoutDirection(threadPhotoRailView) == ViewCompat.LAYOUT_DIRECTION_LTR), RETURN_FROM_MEDIA)); - pendingMembersCard.setVisibility(vs.getGroupRecipient().requireGroupId().isV2() ? View.VISIBLE : View.GONE); + pendingMembersCard.setVisibility(!FeatureFlags.groupsV2manageGroupLinks() && vs.getGroupRecipient().requireGroupId().isV2() ? View.VISIBLE : View.GONE); + groupLinkCard .setVisibility( FeatureFlags.groupsV2manageGroupLinks() && vs.getGroupRecipient().requireGroupId().isV2() ? View.VISIBLE : View.GONE); }); leaveGroup.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); @@ -324,8 +360,7 @@ public class ManageGroupFragment extends LoggingFragment { if (NotificationChannels.supported()) { viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { - customNotificationsButton.setText(hasCustomNotifications ? R.string.ManageGroupActivity_on - : R.string.ManageGroupActivity_off); + customNotificationsButton.setText(booleanToOnOff(hasCustomNotifications)); }); } @@ -343,6 +378,11 @@ public class ManageGroupFragment extends LoggingFragment { }); } + private static int booleanToOnOff(boolean isOn) { + return isOn ? R.string.ManageGroupActivity_on + : R.string.ManageGroupActivity_off; + } + public boolean onMenuItemSelected(@NonNull MenuItem item) { if (item.getItemId() == R.id.action_edit) { startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), getGroupId().requirePush())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java index 640559d0fe..23df816de5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupMentionSettingDialog; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -63,6 +64,7 @@ public class ManageGroupViewModel extends ViewModel { private final LiveData canAddMembers; private final LiveData> members; private final LiveData pendingMemberCount; + private final LiveData pendingAndRequestingCount; private final LiveData disappearingMessageTimer; private final LiveData memberCountSummary; private final LiveData fullMemberCountSummary; @@ -78,6 +80,7 @@ public class ManageGroupViewModel extends ViewModel { private final LiveData canBlockGroup; private final LiveData showLegacyIndicator; private final LiveData mentionSetting; + private final LiveData groupLinkOn; private ManageGroupViewModel(@NonNull Context context, @NonNull ManageGroupRepository manageGroupRepository) { this.context = context; @@ -99,6 +102,7 @@ public class ManageGroupViewModel extends ViewModel { memberListCollapseState, ManageGroupViewModel::filterMemberList); this.pendingMemberCount = liveGroup.getPendingMemberCount(); + this.pendingAndRequestingCount = liveGroup.getPendingAndRequestingMemberCount(); this.showLegacyIndicator = new MutableLiveData<>(groupId.isV1() && FeatureFlags.groupsV2create()); this.memberCountSummary = LiveDataUtil.combineLatest(liveGroup.getMembershipCountDescription(context.getResources()), this.showLegacyIndicator, @@ -119,6 +123,7 @@ public class ManageGroupViewModel extends ViewModel { this.canBlockGroup = Transformations.map(this.groupRecipient, recipient -> !recipient.isBlocked()); this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient, recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting()))); + this.groupLinkOn = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::isEnabled); } @WorkerThread @@ -136,6 +141,10 @@ public class ManageGroupViewModel extends ViewModel { return pendingMemberCount; } + LiveData getPendingAndRequestingCount() { + return pendingAndRequestingCount; + } + LiveData getMemberCountSummary() { return memberCountSummary; } @@ -216,6 +225,10 @@ public class ManageGroupViewModel extends ViewModel { return mentionSetting; } + LiveData getGroupLinkOn() { + return groupLinkOn; + } + void handleExpirationSelection() { manageGroupRepository.getRecipient(groupRecipient -> ExpirationDialog.show(context, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesActivity.java index 44ed25876e..78d194c9ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesActivity.java @@ -10,9 +10,16 @@ import androidx.appcompat.widget.Toolbar; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited.PendingMemberInvitesFragment; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; +/** + * @deprecated With group links FF, this activity is replaced with {@link ManagePendingAndRequestingMembersActivity}. + */ +@Deprecated public class PendingMemberInvitesActivity extends PassphraseRequiredActivity { private static final String GROUP_ID = "GROUP_ID"; @@ -33,6 +40,11 @@ public class PendingMemberInvitesActivity extends PassphraseRequiredActivity { @Override protected void onCreate(Bundle savedInstanceState, boolean ready) { super.onCreate(savedInstanceState, ready); + + if (FeatureFlags.groupsV2manageGroupLinks()) { + throw new AssertionError(); + } + setContentView(R.layout.group_pending_member_invites_activity); if (savedInstanceState == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkUrlAndStatus.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkUrlAndStatus.java new file mode 100644 index 0000000000..1495736ab6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupLinkUrlAndStatus.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.groups.v2; + +import androidx.annotation.NonNull; + +public final class GroupLinkUrlAndStatus { + + public static final GroupLinkUrlAndStatus NONE = new GroupLinkUrlAndStatus(false, false, ""); + + private final boolean enabled; + private final boolean requiresApproval; + private final String url; + + public GroupLinkUrlAndStatus(boolean enabled, + boolean requiresApproval, + @NonNull String url) + { + this.enabled = enabled; + this.requiresApproval = requiresApproval; + this.url = url; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isRequiresApproval() { + return requiresApproval; + } + + public @NonNull String getUrl() { + return url; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java index 8ae675d6b3..826ac04c1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java @@ -5,6 +5,8 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.groups.GroupMasterKey; @@ -163,12 +165,16 @@ public final class MessageGroupContext { } public @NonNull List getAllActivePendingAndRemovedMembers() { - LinkedList memberUuids = new LinkedList<>(); + LinkedList memberUuids = new LinkedList<>(); + DecryptedGroup groupState = decryptedGroupV2Context.getGroupState(); + DecryptedGroupChange groupChange = decryptedGroupV2Context.getChange(); - memberUuids.addAll(DecryptedGroupUtil.membersToUuidList(decryptedGroupV2Context.getGroupState().getMembersList())); - memberUuids.addAll(DecryptedGroupUtil.pendingToUuidList(decryptedGroupV2Context.getGroupState().getPendingMembersList())); - memberUuids.addAll(DecryptedGroupUtil.removedMembersUuidList(decryptedGroupV2Context.getChange())); - memberUuids.addAll(DecryptedGroupUtil.removedPendingMembersUuidList(decryptedGroupV2Context.getChange())); + memberUuids.addAll(DecryptedGroupUtil.membersToUuidList(groupState.getMembersList())); + memberUuids.addAll(DecryptedGroupUtil.pendingToUuidList(groupState.getPendingMembersList())); + + memberUuids.addAll(DecryptedGroupUtil.removedMembersUuidList(groupChange)); + memberUuids.addAll(DecryptedGroupUtil.removedPendingMembersUuidList(groupChange)); + memberUuids.addAll(DecryptedGroupUtil.removedRequestingMembersUuidList(groupChange)); return UuidUtil.filterKnown(memberUuids); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java index e8564ed514..ea90a9fc82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java +++ b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.qr; import android.graphics.Bitmap; import android.graphics.Color; + +import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import org.thoughtcrime.securesms.logging.Log; @@ -15,6 +17,10 @@ public class QrCode { public static final String TAG = QrCode.class.getSimpleName(); public static @NonNull Bitmap create(String data) { + return create(data, Color.BLACK); + } + + public static @NonNull Bitmap create(String data, @ColorInt int foregroundColor) { try { BitMatrix result = new QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, 512, 512); Bitmap bitmap = Bitmap.createBitmap(result.getWidth(), result.getHeight(), Bitmap.Config.ARGB_8888); @@ -22,7 +28,7 @@ public class QrCode { for (int y = 0; y < result.getHeight(); y++) { for (int x = 0; x < result.getWidth(); x++) { if (result.get(x, y)) { - bitmap.setPixel(x, y, Color.BLACK); + bitmap.setPixel(x, y, foregroundColor); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java new file mode 100644 index 0000000000..2053ee36b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ShareCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Objects; + +public final class GroupLinkBottomSheetDialogFragment extends BottomSheetDialogFragment { + + public static final String ARG_GROUP_ID = "group_id"; + + public static void show(@NonNull FragmentManager manager, @NonNull GroupId.V2 groupId) { + GroupLinkBottomSheetDialogFragment fragment = new GroupLinkBottomSheetDialogFragment(); + Bundle args = new Bundle(); + + args.putString(ARG_GROUP_ID, groupId.toString()); + + 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_link_share_bottom_sheet, container, false); + + View shareViaSignalButton = view.findViewById(R.id.group_link_bottom_sheet_share_via_signal_button); + View copyButton = view.findViewById(R.id.group_link_bottom_sheet_copy_button); + View viewQrButton = view.findViewById(R.id.group_link_bottom_sheet_qr_code_button); + View shareBySystemButton = view.findViewById(R.id.group_link_bottom_sheet_share_via_system_button); + + GroupId.V2 groupId = GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(ARG_GROUP_ID))).requireV2(); + + LiveGroup liveGroup = new LiveGroup(groupId); + + liveGroup.getGroupLink().observe(getViewLifecycleOwner(), groupLink -> { + if (!groupLink.isEnabled()) { + Toast.makeText(requireContext(), R.string.GroupLinkBottomSheet_the_link_is_not_currently_active, Toast.LENGTH_SHORT).show(); + dismiss(); + return; + } + + shareViaSignalButton.setOnClickListener(v -> dismiss()); // Todo [Alan] GV2 Add share within signal + shareViaSignalButton.setVisibility(View.GONE); + + copyButton.setOnClickListener(v -> { + Context context = requireContext(); + Util.copyToClipboard(context, groupLink.getUrl()); + Toast.makeText(context, R.string.GroupLinkBottomSheet_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + dismiss(); + }); + + viewQrButton.setOnClickListener(v -> dismiss()); // Todo [Alan] GV2 Add share QR within signal + viewQrButton.setVisibility(View.GONE); + + shareBySystemButton.setOnClickListener(v -> { + ShareCompat.IntentBuilder.from(requireActivity()) + .setType("text/plain") + .setText(groupLink.getUrl()) + .startChooser(); + + dismiss(); + }); + }); + + return view; + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkDialogFragment.java new file mode 100644 index 0000000000..909b642a65 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkDialogFragment.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +public final class ShareableGroupLinkDialogFragment extends DialogFragment { + + private static final String ARG_GROUP_ID = "group_id"; + + private ShareableGroupLinkViewModel viewModel; + private GroupId.V2 groupId; + private SimpleProgressDialog.DismissibleDialog dialog; + + public static DialogFragment create(@NonNull GroupId.V2 groupId) { + DialogFragment fragment = new ShareableGroupLinkDialogFragment(); + Bundle args = new Bundle(); + + args.putString(ARG_GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(STYLE_NO_FRAME, ThemeUtil.isDarkTheme(requireActivity()) ? R.style.TextSecure_DarkTheme + : R.style.TextSecure_LightTheme); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.shareable_group_link_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViewModel(); + initializeViews(view); + } + + private void initializeViewModel() { + //noinspection ConstantConditions + groupId = GroupId.parseOrThrow(requireArguments().getString(ARG_GROUP_ID)).requireV2(); + + ShareableGroupLinkRepository repository = new ShareableGroupLinkRepository(requireContext(), groupId); + ShareableGroupLinkViewModel.Factory factory = new ShareableGroupLinkViewModel.Factory(groupId, repository); + + viewModel = ViewModelProviders.of(this, factory).get(ShareableGroupLinkViewModel.class); + } + + private void initializeViews(@NonNull View view) { + SwitchCompat shareableGroupLinkSwitch = view.findViewById(R.id.shareable_group_link_enable_switch); + TextView shareableGroupLinkDisplay = view.findViewById(R.id.shareable_group_link_display); + SwitchCompat approveNewMembersSwitch = view.findViewById(R.id.shareable_group_link_approve_new_members_switch); + View shareableGroupLinkRow = view.findViewById(R.id.shareable_group_link_row); + View shareRow = view.findViewById(R.id.shareable_group_link_share_row); + View resetLinkRow = view.findViewById(R.id.shareable_group_link_reset_link_row); + View approveNewMembersRow = view.findViewById(R.id.shareable_group_link_approve_new_members_row); + + Toolbar toolbar = view.findViewById(R.id.shareable_group_link_toolbar); + + toolbar.setNavigationOnClickListener(v -> dismissAllowingStateLoss()); + + viewModel.getGroupLink().observe(getViewLifecycleOwner(), groupLink -> { + shareableGroupLinkSwitch.setChecked(groupLink.isEnabled()); + approveNewMembersSwitch.setChecked(groupLink.isRequiresApproval()); + shareableGroupLinkDisplay.setText(groupLink.getUrl()); + }); + + shareRow.setOnClickListener(v -> GroupLinkBottomSheetDialogFragment.show(requireFragmentManager(), groupId)); + + shareableGroupLinkRow.setOnClickListener(v -> viewModel.onToggleGroupLink(requireContext())); + approveNewMembersRow.setOnClickListener(v -> viewModel.onToggleApproveMembers(requireContext())); + resetLinkRow.setOnClickListener(v -> viewModel.onResetLink(requireContext())); + + viewModel.getToasts().observe(getViewLifecycleOwner(), t -> Toast.makeText(requireContext(), t, Toast.LENGTH_SHORT).show()); + + viewModel.getBusy().observe(getViewLifecycleOwner(), busy -> { + if (busy) { + if (dialog == null) { + dialog = SimpleProgressDialog.showDelayed(requireContext()); + } + } else { + if (dialog != null) { + dialog.dismiss(); + dialog = null; + } + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java new file mode 100644 index 0000000000..bc5e9e903d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.storageservice.protos.groups.AccessControl; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.io.IOException; + +final class ShareableGroupLinkRepository { + + private final Context context; + private final GroupId.V2 groupId; + + ShareableGroupLinkRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + void cycleGroupLinkPassword(@NonNull AsynchronousCallback.WorkerThread callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.cycleGroupLinkPassword(context, groupId); + callback.onComplete(null); + } catch (GroupNotAMemberException | GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupChangeBusyException e) { + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + void toggleGroupLinkEnabled(@NonNull AsynchronousCallback.WorkerThread callback) { + setGroupLinkEnabledState(toggleGroupLinkState(true, false), callback); + } + + void toggleGroupLinkApprovalRequired(@NonNull AsynchronousCallback.WorkerThread callback) { + setGroupLinkEnabledState(toggleGroupLinkState(false, true), callback); + } + + private void setGroupLinkEnabledState(@NonNull GroupManager.GroupLinkState state, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.UNBOUNDED.execute(() -> { + try { + GroupManager.setGroupLinkEnabledState(context, groupId, state); + callback.onComplete(null); + } catch (GroupNotAMemberException | GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupChangeBusyException e) { + callback.onError(GroupChangeFailureReason.fromException(e)); + } + }); + } + + @WorkerThread + private GroupManager.GroupLinkState toggleGroupLinkState(boolean toggleEnabled, boolean toggleApprovalNeeded) { + AccessControl.AccessRequired currentState = DatabaseFactory.getGroupDatabase(context) + .getGroup(groupId) + .get() + .requireV2GroupProperties() + .getDecryptedGroup() + .getAccessControl() + .getAddFromInviteLink(); + + boolean enabled; + boolean approvalNeeded; + + switch (currentState) { + case UNKNOWN: + case UNSATISFIABLE: + case UNRECOGNIZED: + case MEMBER: + enabled = false; + approvalNeeded = false; + break; + case ANY: + enabled = true; + approvalNeeded = false; + break; + case ADMINISTRATOR: + enabled = true; + approvalNeeded = true; + break; + default: throw new AssertionError(); + } + + if (toggleApprovalNeeded) { + approvalNeeded = !approvalNeeded; + } + + if (toggleEnabled) { + enabled = !enabled; + if (enabled) approvalNeeded = true; + } + + if (approvalNeeded && enabled) { + return GroupManager.GroupLinkState.ENABLED_WITH_APPROVAL; + } else { + if (enabled) { + return GroupManager.GroupLinkState.ENABLED; + } + } + + return GroupManager.GroupLinkState.DISABLED; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java new file mode 100644 index 0000000000..c95be3d5e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.SingleLiveEvent; + +final class ShareableGroupLinkViewModel extends ViewModel { + + private final ShareableGroupLinkRepository repository; + private final LiveData groupLink; + private final SingleLiveEvent toasts; + private final SingleLiveEvent busy; + + private ShareableGroupLinkViewModel(@NonNull GroupId.V2 groupId, @NonNull ShareableGroupLinkRepository repository) { + this.repository = repository; + this.groupLink = new LiveGroup(groupId).getGroupLink(); + this.toasts = new SingleLiveEvent<>(); + this.busy = new SingleLiveEvent<>(); + } + + LiveData getGroupLink() { + return groupLink; + } + + LiveData getToasts() { + return toasts; + } + + LiveData getBusy() { + return busy; + } + + void onToggleGroupLink(@NonNull Context context) { + busy.setValue(true); + repository.toggleGroupLinkEnabled(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + busy.postValue(false); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + busy.postValue(false); + toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error))); + } + }); + } + + void onToggleApproveMembers(@NonNull Context context) { + busy.setValue(true); + repository.toggleGroupLinkApprovalRequired(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + busy.postValue(false); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + busy.postValue(false); + toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error))); + } + }); + } + + void onResetLink(@NonNull Context context) { + busy.setValue(true); + repository.cycleGroupLinkPassword(new AsynchronousCallback.WorkerThread() { + @Override + public void onComplete(@Nullable Void result) { + busy.postValue(false); + toasts.postValue(context.getString(R.string.ShareableGroupLinkDialogFragment__group_link_reset)); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + busy.postValue(false); + toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error))); + } + }); + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final GroupId.V2 groupId; + private final ShareableGroupLinkRepository repository; + + public Factory(@NonNull GroupId.V2 groupId, @NonNull ShareableGroupLinkRepository repository) { + this.groupId = groupId; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ShareableGroupLinkViewModel(groupId, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 3fb563463e..0e9068e32a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -57,6 +57,7 @@ public final class FeatureFlags { private static final String GROUPS_V2 = "android.groupsv2.3"; private static final String GROUPS_V2_CREATE = "android.groupsv2.create.3"; private static final String GROUPS_V2_JOIN_VERSION = "android.groupsv2.joinVersion"; + private static final String GROUPS_V2_LINKS_VERSION = "android.groupsv2.manageGroupLinksVersion"; private static final String GROUPS_V2_CAPACITY = "global.groupsv2.maxGroupSize"; private static final String CDS = "android.cds.4"; private static final String INTERNAL_USER = "android.internalUser"; @@ -208,6 +209,11 @@ public final class FeatureFlags { !SignalStore.internalValues().gv2DoNotCreateGv2Groups(); } + /** Allow creation and managing of group links. */ + public static boolean groupsV2manageGroupLinks() { + return groupsV2() && getVersionFlag(GROUPS_V2_LINKS_VERSION) == VersionFlag.ON; + } + private static boolean groupsV2LatestFlag() { return getBoolean(GROUPS_V2, false); } @@ -231,11 +237,12 @@ public final class FeatureFlags { * You must still check GV2 capabilities to respect linked devices. */ public static GroupJoinStatus clientLocalGroupJoinStatus() { - int groupJoinVersion = getInteger(GROUPS_V2_JOIN_VERSION, 0); - - if (groupJoinVersion == 0) return GroupJoinStatus.COMING_SOON; - else if (groupJoinVersion > BuildConfig.CANONICAL_VERSION_CODE) return GroupJoinStatus.UPDATE_TO_JOIN; - else return GroupJoinStatus.LOCAL_CAN_JOIN; + switch (getVersionFlag(GROUPS_V2_JOIN_VERSION)) { + case ON_IN_FUTURE_VERSION: return GroupJoinStatus.UPDATE_TO_JOIN; + case ON : return GroupJoinStatus.LOCAL_CAN_JOIN; + case OFF : + default : return GroupJoinStatus.COMING_SOON; + } } public enum GroupJoinStatus { @@ -385,6 +392,31 @@ public final class FeatureFlags { return changes; } + private static @NonNull VersionFlag getVersionFlag(@NonNull String key) { + int versionFromKey = getInteger(key, 0); + + if (versionFromKey == 0) { + return VersionFlag.OFF; + } + + if (BuildConfig.CANONICAL_VERSION_CODE >= versionFromKey) { + return VersionFlag.ON; + } else { + return VersionFlag.ON_IN_FUTURE_VERSION; + } + } + + private enum VersionFlag { + /** The flag is no set */ + OFF, + + /** The flag is set on for a version higher than the current client version */ + ON_IN_FUTURE_VERSION, + + /** The flag is set on for this version or earlier */ + ON + } + private static boolean getBoolean(@NonNull String key, boolean defaultValue) { Boolean forced = (Boolean) FORCED_VALUES.get(key); if (forced != null) { diff --git a/app/src/main/res/drawable/circle_ultramarine.xml b/app/src/main/res/drawable/circle_ultramarine.xml new file mode 100644 index 0000000000..a852d08bdc --- /dev/null +++ b/app/src/main/res/drawable/circle_ultramarine.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_check_28_tinted.xml b/app/src/main/res/drawable/ic_check_28_tinted.xml new file mode 100644 index 0000000000..8648619e07 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_28_tinted.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_copy_outline_24_tinted.xml b/app/src/main/res/drawable/ic_copy_outline_24_tinted.xml new file mode 100644 index 0000000000..45eb5ee05c --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_outline_24_tinted.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy_solid_24_tinted.xml b/app/src/main/res/drawable/ic_copy_solid_24_tinted.xml new file mode 100644 index 0000000000..7f4930f711 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_solid_24_tinted.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_deny_28_tinted.xml b/app/src/main/res/drawable/ic_deny_28_tinted.xml new file mode 100644 index 0000000000..f39c769f7e --- /dev/null +++ b/app/src/main/res/drawable/ic_deny_28_tinted.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_qrcode_24_tinted.xml b/app/src/main/res/drawable/ic_qrcode_24_tinted.xml new file mode 100644 index 0000000000..043b44cd7d --- /dev/null +++ b/app/src/main/res/drawable/ic_qrcode_24_tinted.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_reset_24_tinted.xml b/app/src/main/res/drawable/ic_reset_24_tinted.xml new file mode 100644 index 0000000000..b680b8911b --- /dev/null +++ b/app/src/main/res/drawable/ic_reset_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_outline_24_tinted.xml b/app/src/main/res/drawable/ic_share_outline_24_tinted.xml new file mode 100644 index 0000000000..2945397dbc --- /dev/null +++ b/app/src/main/res/drawable/ic_share_outline_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_solid_24_tinted.xml b/app/src/main/res/drawable/ic_share_solid_24_tinted.xml new file mode 100644 index 0000000000..c534116435 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_solid_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/custom_notifications_dialog_fragment.xml b/app/src/main/res/layout/custom_notifications_dialog_fragment.xml index 7c2ea4b6d6..78bd783ae7 100644 --- a/app/src/main/res/layout/custom_notifications_dialog_fragment.xml +++ b/app/src/main/res/layout/custom_notifications_dialog_fragment.xml @@ -22,9 +22,8 @@ android:layout_marginStart="12dp" android:layout_marginTop="16dp" android:text="@string/CustomNotificationsDialogFragment__messages" - android:textAppearance="@style/TextAppearance.Signal.Body2" + android:textAppearance="@style/TextAppearance.Signal.Body2.Bold" android:textColor="?attr/colorAccent" - android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/custom_notifications_toolbar" /> @@ -39,7 +38,6 @@ android:orientation="horizontal" android:paddingStart="12dp" android:paddingEnd="12dp" - android:text="@string/CustomNotificationsDialogFragment__notification_sound" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/custom_notifications_message_section_header"> @@ -77,7 +75,6 @@ android:orientation="horizontal" android:paddingStart="12dp" android:paddingEnd="12dp" - android:text="@string/CustomNotificationsDialogFragment__notification_sound" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/custom_notifications_row"> @@ -116,7 +113,6 @@ android:orientation="horizontal" android:paddingStart="12dp" android:paddingEnd="12dp" - android:text="@string/CustomNotificationsDialogFragment__notification_sound" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/custom_notifications_sound_row"> @@ -159,9 +155,8 @@ android:layout_marginStart="12dp" android:layout_marginTop="36dp" android:text="@string/CustomNotificationsDialogFragment__call_settings" - android:textAppearance="@style/TextAppearance.Signal.Body2" + android:textAppearance="@style/TextAppearance.Signal.Body2.Bold" android:textColor="?attr/colorAccent" - android:textStyle="bold" android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -178,7 +173,6 @@ android:orientation="horizontal" android:paddingStart="12dp" android:paddingEnd="12dp" - android:text="@string/CustomNotificationsDialogFragment__notification_sound" android:visibility="gone" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/custom_notifications_call_settings_section_header" @@ -215,7 +209,6 @@ android:orientation="horizontal" android:paddingStart="12dp" android:paddingEnd="12dp" - android:text="@string/CustomNotificationsDialogFragment__notification_sound" android:visibility="gone" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/custom_notifications_ringtone_row" diff --git a/app/src/main/res/layout/group_link_share_bottom_sheet.xml b/app/src/main/res/layout/group_link_share_bottom_sheet.xml new file mode 100644 index 0000000000..8fb4f1f3c3 --- /dev/null +++ b/app/src/main/res/layout/group_link_share_bottom_sheet.xml @@ -0,0 +1,61 @@ + + + + + +