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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/group_manage_fragment.xml b/app/src/main/res/layout/group_manage_fragment.xml
index 4b0e0f9602..5da6d4f6da 100644
--- a/app/src/main/res/layout/group_manage_fragment.xml
+++ b/app/src/main/res/layout/group_manage_fragment.xml
@@ -509,13 +509,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/group_pending_member_invites_fragment.xml b/app/src/main/res/layout/group_pending_member_invites_fragment.xml
index 2eb2138010..798c935728 100644
--- a/app/src/main/res/layout/group_pending_member_invites_fragment.xml
+++ b/app/src/main/res/layout/group_pending_member_invites_fragment.xml
@@ -7,7 +7,7 @@
android:background="?pref_divider"
android:fillViewport="true"
android:paddingTop="0dp"
- tools:context="org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesFragment">
+ tools:context="org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited.PendingMemberInvitesFragment">
diff --git a/app/src/main/res/layout/group_recipient_requesting_list_item.xml b/app/src/main/res/layout/group_recipient_requesting_list_item.xml
new file mode 100644
index 0000000000..8bfebe8aef
--- /dev/null
+++ b/app/src/main/res/layout/group_recipient_requesting_list_item.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/group_requesting_member_fragment.xml b/app/src/main/res/layout/group_requesting_member_fragment.xml
new file mode 100644
index 0000000000..ae85e60ad9
--- /dev/null
+++ b/app/src/main/res/layout/group_requesting_member_fragment.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/shareable_group_link_dialog_fragment.xml b/app/src/main/res/layout/shareable_group_link_dialog_fragment.xml
new file mode 100644
index 0000000000..485872ca6f
--- /dev/null
+++ b/app/src/main/res/layout/shareable_group_link_dialog_fragment.xml
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index cd8aca9cc0..152b7e1fd0 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -190,6 +190,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7dbd84ddd9..2293e55c5b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -469,6 +469,7 @@
Anyone
All members
Only admins
+ No one
Unknown
- @string/GroupManagement_access_level_all_members
@@ -505,6 +506,8 @@
Pending group invites
+ Requests
+ Invites
People you invited
You have no pending invites.
Invites by other group members
@@ -521,6 +524,13 @@
- Error revoking invite
- Error revoking invites
+
+
+ Pending member requests
+ No member requests to show.
+ People on this list are attempting to join this group via the sharable group link.
+ Added "%1$s"
+ Denied "%1$s"
Done
@@ -559,10 +569,12 @@
Disappearing messages
Pending group invites
+ Member requests & invites
Add members
Edit group info
Choose who can edit the group name, avatar, and disappearing messages.
Choose who can add or invite new members.
+ Sharable group link
Block group
Unblock group
Leave group
@@ -647,6 +659,24 @@
Enabled
Disabled
Default
+
+
+ Shareable group link
+ Manage & share
+ Group link
+ Share
+ Reset link
+ Member requests
+ Approve new members
+ Enabled
+ Disabled
+ Default
+ Group link reset
+
+
+ QR code
+ People who scan this code will be able to join your group. Admins will still need to approve new members if you have that setting turned on.
+ Share code
Do you want to revoke the invite you sent to %1$s?
@@ -679,6 +709,12 @@
Update Signal
Group link is not valid
+
+ Add “%1$s” to the group?
+ Deny request from “%1$s”?
+ Add
+ Deny
+
Group avatar
Avatar
@@ -2546,6 +2582,8 @@
Copied to clipboard
Admin
+ Approve
+ Deny
@@ -2556,6 +2594,15 @@
Legacy Groups can’t be converted into New Groups, but you can create a New Group with the same members.
To create a New Group, all members should update to the latest version of Signal.
+
+
+ Share via Signal
+ Copy
+ QR Code
+ Share
+ Copied to clipboard
+ The link is not currently active
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index a671a83f08..a68b6f852f 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -444,6 +444,11 @@
- @drawable/invite_edit_text_background_light
- @drawable/ic_share_outline_24
+ - @drawable/ic_share_outline_24_tinted
+ - @drawable/ic_copy_outline_24_tinted
+ - @drawable/ic_qrcode_24_tinted
+ - @drawable/ic_reset_24_tinted
+
- @color/grey_100
- @color/grey_400
- @color/core_grey_02
@@ -489,6 +494,11 @@
- @drawable/invite_edit_text_background_dark
- @drawable/ic_share_solid_24_dark
+ - @drawable/ic_share_solid_24_tinted
+ - @drawable/ic_copy_solid_24_tinted
+ - @drawable/ic_qrcode_24_tinted
+ - @drawable/ic_reset_24_tinted
+
- @color/core_grey_05
- @color/core_grey_25
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java
index 1fca410336..b9276e3618 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java
@@ -111,6 +111,24 @@ public final class DecryptedGroupUtil {
return uuidList;
}
+ /**
+ * Will not return any non-decryptable member UUIDs.
+ */
+ public static ArrayList removedRequestingMembersUuidList(DecryptedGroupChange groupChange) {
+ List deleteRequestingMembers = groupChange.getDeleteRequestingMembersList();
+ ArrayList uuidList = new ArrayList<>(deleteRequestingMembers.size());
+
+ for (ByteString member : deleteRequestingMembers) {
+ UUID uuid = toUuid(member);
+
+ if(!UuidUtil.UNKNOWN_UUID.equals(uuid)) {
+ uuidList.add(uuid);
+ }
+ }
+
+ return uuidList;
+ }
+
public static UUID toUuid(DecryptedMember member) {
return toUuid(member.getUuid());
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
index 3a49ec2c75..e2674bfd70 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
@@ -1632,7 +1632,6 @@ public class PushServiceSocket {
.readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
.build();
-// Log.d(TAG, "Opening URL: " + String.format("%s%s", connectionHolder.getUrl(), path));
Log.d(TAG, "Opening URL: ");
Request.Builder request = new Request.Builder().url(connectionHolder.getUrl() + path);
@@ -1960,8 +1959,13 @@ public class PushServiceSocket {
public GroupChange patchGroupsV2Group(GroupChange.Actions groupChange, String authorization, Optional groupLinkPassword)
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
{
- String path = groupLinkPassword.transform(p -> String.format(GROUPSV2_GROUP_PASSWORD, Base64UrlSafe.encodeBytesWithoutPadding(p)))
- .or(GROUPSV2_GROUP);
+ String path;
+
+ if (groupLinkPassword.isPresent()) {
+ path = String.format(GROUPSV2_GROUP_PASSWORD, Base64UrlSafe.encodeBytesWithoutPadding(groupLinkPassword.get()));
+ } else {
+ path = GROUPSV2_GROUP;
+ }
ResponseBody response = makeStorageRequest(authorization,
path,