Manage group links behind feature flag.

This commit is contained in:
Alan Evans
2020-08-26 15:59:34 -03:00
parent 860f06ec9e
commit bfed03b7b5
51 changed files with 2177 additions and 80 deletions

View File

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

View File

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

View File

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

View File

@@ -292,6 +292,17 @@ final class GroupManagerV2 {
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult approveRequests(@NonNull Collection<RecipientId> recipientIds)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Set<UUID> 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<RecipientId> 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();
}

View File

@@ -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> recipient;
private final LiveData<GroupDatabase.GroupRecord> groupRecord;
private final LiveData<List<GroupMemberEntry.FullMember>> fullMembers;
private final GroupDatabase groupDatabase;
private final LiveData<Recipient> recipient;
private final LiveData<GroupDatabase.GroupRecord> groupRecord;
private final LiveData<List<GroupMemberEntry.FullMember>> fullMembers;
private final LiveData<List<GroupMemberEntry.RequestingMember>> requestingMembers;
private final LiveData<GroupLinkUrlAndStatus> groupLink;
public LiveGroup(@NonNull GroupId groupId) {
Context context = ApplicationDependencies.getApplication();
MutableLiveData<LiveRecipient> 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<GroupDatabase.V2GroupProperties> 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<List<GroupMemberEntry.FullMember>> mapToFullMembers(@NonNull LiveData<GroupDatabase.GroupRecord> 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<List<GroupMemberEntry.RequestingMember>> mapToRequestingMembers(@NonNull LiveData<GroupDatabase.GroupRecord> groupRecord) {
return LiveDataUtil.mapAsync(groupRecord,
g -> {
if (!g.isV2Group()) {
return Collections.emptyList();
}
boolean selfAdmin = g.isAdmin(Recipient.self());
List<DecryptedRequestingMember> 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<String> 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<Integer> getPendingAndRequestingMemberCount() {
return Transformations.map(groupRecord, g -> {
if (g.isV2Group()) {
DecryptedGroup decryptedGroup = g.requireV2GroupProperties().getDecryptedGroup();
return decryptedGroup.getPendingMembersCount() + decryptedGroup.getRequestingMembersCount();
}
return 0;
});
}
public LiveData<GroupAccessControl> getMembershipAdditionAccessControl() {
return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getMembershipAdditionAccessControl);
}
@@ -110,6 +176,10 @@ public final class LiveGroup {
return fullMembers;
}
public LiveData<List<GroupMemberEntry.RequestingMember>> getRequestingMembers() {
return requestingMembers;
}
public LiveData<Integer> 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<GroupLinkUrlAndStatus> getGroupLink() {
return groupLink;
}
}

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
private static final int OWN_INVITE_PENDING = 1;
private static final int OTHER_INVITE_PENDING_COUNT = 2;
private static final int NEW_GROUP_CANDIDATE = 3;
private static final int REQUESTING_MEMBER = 4;
private final List<GroupMemberEntry> data = new ArrayList<>();
private final Set<GroupMemberEntry> selection = new HashSet<>();
@@ -101,6 +102,14 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
recipientClickListener,
recipientLongClickListener,
selectionChangeListener);
case REQUESTING_MEMBER:
return new RequestingMemberViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_requesting_list_item, parent, false),
recipientClickListener,
recipientLongClickListener,
adminActionsListener,
selectionChangeListener);
default:
throw new AssertionError();
}
@@ -140,6 +149,8 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
return OTHER_INVITE_PENDING_COUNT;
} else if (groupMemberEntry instanceof GroupMemberEntry.NewGroupCandidate) {
return NEW_GROUP_CANDIDATE;
} else if (groupMemberEntry instanceof GroupMemberEntry.RequestingMember) {
return REQUESTING_MEMBER;
}
throw new AssertionError();
@@ -159,7 +170,7 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
final PopupMenuView popupMenu;
final View popupMenuContainer;
final ProgressBar busyProgress;
final View admin;
@Nullable final View admin;
final SelectionChangeListener selectionChangeListener;
@Nullable final RecipientClickListener recipientClickListener;
@Nullable final AdminActionsListener adminActionsListener;
@@ -225,7 +236,9 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) {
busyProgress.setVisibility(View.GONE);
admin.setVisibility(View.GONE);
if (admin != null) {
admin.setVisibility(View.GONE);
}
hideMenu();
itemView.setOnClickListener(null);
@@ -268,7 +281,9 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
bindRecipient(fullMember.getMember());
bindRecipientClick(fullMember.getMember());
admin.setVisibility(fullMember.isAdmin() ? View.VISIBLE : View.INVISIBLE);
if (admin != null) {
admin.setVisibility(fullMember.isAdmin() ? View.VISIBLE : View.INVISIBLE);
}
}
}
final static class NewGroupInviteeViewHolder extends ViewHolder {
@@ -379,6 +394,46 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
}
}
final static class RequestingMemberViewHolder extends ViewHolder {
private final View approveRequest;
private final View denyRequest;
RequestingMemberViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable RecipientLongClickListener recipientLongClickListener,
@Nullable AdminActionsListener adminActionsListener,
@NonNull SelectionChangeListener selectionChangeListener)
{
super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener, selectionChangeListener);
approveRequest = itemView.findViewById(R.id.request_approve);
denyRequest = itemView.findViewById(R.id.request_deny);
}
@Override
void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) {
super.bind(memberEntry, isSelected);
GroupMemberEntry.RequestingMember requestingMember = (GroupMemberEntry.RequestingMember) memberEntry;
if (adminActionsListener != null && requestingMember.isApprovableDeniable()) {
approveRequest.setVisibility(View.VISIBLE);
denyRequest .setVisibility(View.VISIBLE);
approveRequest.setOnClickListener(v -> 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) {

View File

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

View File

@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites;
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.invited;
import android.content.Context;

View File

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

View File

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

View File

@@ -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<List<GroupMemberEntry.PendingMember>> whoYouInvited = new DefaultValueLiveData<>(Collections.emptyList());
private final DefaultValueLiveData<List<GroupMemberEntry.UnknownPendingMemberCount>> 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<GroupMemberEntry.PendingMember> byMe = new ArrayList<>(inviteeResult.getByMe().size());
List<GroupMemberEntry.UnknownPendingMemberCount> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new PendingMemberInvitesViewModel(context, new PendingMemberRepository(context.getApplicationContext(), groupId));
return (T) new PendingMemberInvitesViewModel(context, new PendingMemberInvitesRepository(context.getApplicationContext(), groupId));
}
}
}

View File

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

View File

@@ -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<String> toasts;
private final LiveData<List<GroupMemberEntry.RequestingMember>> 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<List<GroupMemberEntry.RequestingMember>> getRequesting() {
return requesting;
}
LiveData<String> 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<RecipientId> memberAsSet = Collections.singleton(requestingMember.getRequester().getId());
if (approve) {
requestingMember.setBusy(true);
requestingMemberRepository.approveRequests(memberAsSet, new AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason>() {
@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<Void, GroupChangeFailureReason>() {
@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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new RequestingMemberInvitesViewModel(context, groupId, new RequestingMemberRepository(context.getApplicationContext(), groupId));
}
}
}

View File

@@ -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<RecipientId> recipientIds,
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> 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<RecipientId> recipientIds,
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> 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));
}
});
}
}

View File

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

View File

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

View File

@@ -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<Boolean> canAddMembers;
private final LiveData<List<GroupMemberEntry.FullMember>> members;
private final LiveData<Integer> pendingMemberCount;
private final LiveData<Integer> pendingAndRequestingCount;
private final LiveData<String> disappearingMessageTimer;
private final LiveData<String> memberCountSummary;
private final LiveData<String> fullMemberCountSummary;
@@ -78,6 +80,7 @@ public class ManageGroupViewModel extends ViewModel {
private final LiveData<Boolean> canBlockGroup;
private final LiveData<Boolean> showLegacyIndicator;
private final LiveData<String> mentionSetting;
private final LiveData<Boolean> 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<Integer> getPendingAndRequestingCount() {
return pendingAndRequestingCount;
}
LiveData<String> getMemberCountSummary() {
return memberCountSummary;
}
@@ -216,6 +225,10 @@ public class ManageGroupViewModel extends ViewModel {
return mentionSetting;
}
LiveData<Boolean> getGroupLinkOn() {
return groupLinkOn;
}
void handleExpirationSelection() {
manageGroupRepository.getRecipient(groupRecipient ->
ExpirationDialog.show(context,

View File

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

View File

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

View File

@@ -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<UUID> getAllActivePendingAndRemovedMembers() {
LinkedList<UUID> memberUuids = new LinkedList<>();
LinkedList<UUID> 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);
}

View File

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

View File

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

View File

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

View File

@@ -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<Void, GroupChangeFailureReason> 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<Void, GroupChangeFailureReason> callback) {
setGroupLinkEnabledState(toggleGroupLinkState(true, false), callback);
}
void toggleGroupLinkApprovalRequired(@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> callback) {
setGroupLinkEnabledState(toggleGroupLinkState(false, true), callback);
}
private void setGroupLinkEnabledState(@NonNull GroupManager.GroupLinkState state,
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> 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;
}
}

View File

@@ -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<GroupLinkUrlAndStatus> groupLink;
private final SingleLiveEvent<String> toasts;
private final SingleLiveEvent<Boolean> 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<GroupLinkUrlAndStatus> getGroupLink() {
return groupLink;
}
LiveData<String> getToasts() {
return toasts;
}
LiveData<Boolean> getBusy() {
return busy;
}
void onToggleGroupLink(@NonNull Context context) {
busy.setValue(true);
repository.toggleGroupLinkEnabled(new AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason>() {
@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<Void, GroupChangeFailureReason>() {
@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<Void, GroupChangeFailureReason>() {
@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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ShareableGroupLinkViewModel(groupId, repository));
}
}
}

View File

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