Add support for manual initiation of GV1->GV2 migrations.
This commit is contained in:
parent
4eaa6ebb47
commit
7e347f5cce
27 changed files with 1161 additions and 424 deletions
|
@ -0,0 +1,27 @@
|
||||||
|
package org.thoughtcrime.securesms.components.reminder;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.R;
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a reminder to upgrade a group to GV2.
|
||||||
|
*/
|
||||||
|
public class GroupsV1MigrationInitiationReminder extends Reminder {
|
||||||
|
|
||||||
|
public GroupsV1MigrationInitiationReminder(@NonNull Context context) {
|
||||||
|
super(null, context.getString(R.string.GroupsV1MigrationInitiationReminder_to_access_new_features_like_mentions));
|
||||||
|
addAction(new Action(context.getString(R.string.GroupsV1MigrationInitiationReminder_update_group), R.id.reminder_action_gv1_initiation_update_group));
|
||||||
|
addAction(new Action(context.getResources().getString(R.string.GroupsV1MigrationInitiationReminder_not_now), R.id.reminder_action_gv1_initiation_not_now));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isDismissable() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
|
||||||
import org.thoughtcrime.securesms.components.location.SignalPlace;
|
import org.thoughtcrime.securesms.components.location.SignalPlace;
|
||||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||||
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
|
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
|
||||||
|
import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationInitiationReminder;
|
||||||
import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder;
|
import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder;
|
||||||
import org.thoughtcrime.securesms.components.reminder.PendingGroupJoinRequestsReminder;
|
import org.thoughtcrime.securesms.components.reminder.PendingGroupJoinRequestsReminder;
|
||||||
import org.thoughtcrime.securesms.components.reminder.Reminder;
|
import org.thoughtcrime.securesms.components.reminder.Reminder;
|
||||||
|
@ -167,6 +168,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors;
|
||||||
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
|
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
|
||||||
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity;
|
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity;
|
||||||
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
|
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
|
||||||
|
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment;
|
||||||
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog;
|
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog;
|
||||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||||
import org.thoughtcrime.securesms.invites.InviteReminderModel;
|
import org.thoughtcrime.securesms.invites.InviteReminderModel;
|
||||||
|
@ -455,7 +457,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
initializeMentionsViewModel();
|
initializeMentionsViewModel();
|
||||||
initializeEnabledCheck();
|
initializeEnabledCheck();
|
||||||
initializePendingRequestsBanner();
|
initializePendingRequestsBanner();
|
||||||
initializeGroupV1MigrationSuggestionsBanner();
|
initializeGroupV1MigrationsBanners();
|
||||||
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
|
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(Boolean result) {
|
public void onSuccess(Boolean result) {
|
||||||
|
@ -1550,11 +1552,14 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
.observe(this, actionablePendingGroupRequests -> updateReminders());
|
.observe(this, actionablePendingGroupRequests -> updateReminders());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeGroupV1MigrationSuggestionsBanner() {
|
private void initializeGroupV1MigrationsBanners() {
|
||||||
groupViewModel.getGroupV1MigrationSuggestions()
|
groupViewModel.getGroupV1MigrationSuggestions()
|
||||||
.observe(this, s -> updateReminders());
|
.observe(this, s -> updateReminders());
|
||||||
|
groupViewModel.getShowGroupsV1MigrationBanner()
|
||||||
|
.observe(this, b -> updateReminders());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private ListenableFuture<Boolean> initializeDraftFromDatabase() {
|
private ListenableFuture<Boolean> initializeDraftFromDatabase() {
|
||||||
SettableFuture<Boolean> future = new SettableFuture<>();
|
SettableFuture<Boolean> future = new SettableFuture<>();
|
||||||
|
|
||||||
|
@ -1711,6 +1716,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
Optional<Reminder> inviteReminder = inviteReminderModel.getReminder();
|
Optional<Reminder> inviteReminder = inviteReminderModel.getReminder();
|
||||||
Integer actionableRequestingMembers = groupViewModel.getActionableRequestingMembers().getValue();
|
Integer actionableRequestingMembers = groupViewModel.getActionableRequestingMembers().getValue();
|
||||||
List<RecipientId> gv1MigrationSuggestions = groupViewModel.getGroupV1MigrationSuggestions().getValue();
|
List<RecipientId> gv1MigrationSuggestions = groupViewModel.getGroupV1MigrationSuggestions().getValue();
|
||||||
|
Boolean gv1MigrationBanner = groupViewModel.getShowGroupsV1MigrationBanner().getValue();
|
||||||
|
|
||||||
if (UnauthorizedReminder.isEligible(this)) {
|
if (UnauthorizedReminder.isEligible(this)) {
|
||||||
reminderView.get().showReminder(new UnauthorizedReminder(this));
|
reminderView.get().showReminder(new UnauthorizedReminder(this));
|
||||||
|
@ -1735,6 +1741,15 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
startActivity(ManagePendingAndRequestingMembersActivity.newIntent(this, getRecipient().getGroupId().get().requireV2()));
|
startActivity(ManagePendingAndRequestingMembersActivity.newIntent(this, getRecipient().getGroupId().get().requireV2()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (gv1MigrationBanner == Boolean.TRUE && recipient.get().isPushV1Group()) {
|
||||||
|
reminderView.get().showReminder(new GroupsV1MigrationInitiationReminder(this));
|
||||||
|
reminderView.get().setOnActionClickListener(actionId -> {
|
||||||
|
if (actionId == R.id.reminder_action_gv1_initiation_update_group) {
|
||||||
|
GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(getSupportFragmentManager(), recipient.getId());
|
||||||
|
} else if (actionId == R.id.reminder_action_gv1_initiation_not_now) {
|
||||||
|
groupViewModel.onMigrationInitiationReminderBannerDismissed(recipient.getId());
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (gv1MigrationSuggestions != null && gv1MigrationSuggestions.size() > 0 && recipient.get().isPushV2Group()) {
|
} else if (gv1MigrationSuggestions != null && gv1MigrationSuggestions.size() > 0 && recipient.get().isPushV2Group()) {
|
||||||
reminderView.get().showReminder(new GroupsV1MigrationSuggestionsReminder(this, gv1MigrationSuggestions));
|
reminderView.get().showReminder(new GroupsV1MigrationSuggestionsReminder(this, gv1MigrationSuggestions));
|
||||||
reminderView.get().setOnActionClickListener(actionId -> {
|
reminderView.get().setOnActionClickListener(actionId -> {
|
||||||
|
|
|
@ -91,7 +91,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.GroupId;
|
import org.thoughtcrime.securesms.groups.GroupId;
|
||||||
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationBottomSheetDialogFragment;
|
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment;
|
||||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob;
|
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
@ -1427,7 +1427,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onGroupMigrationLearnMoreClicked(@NonNull List<RecipientId> pendingRecipients) {
|
public void onGroupMigrationLearnMoreClicked(@NonNull List<RecipientId> pendingRecipients) {
|
||||||
GroupsV1MigrationBottomSheetDialogFragment.showForLearnMore(requireFragmentManager(), pendingRecipients);
|
GroupsV1MigrationInfoBottomSheetDialogFragment.showForLearnMore(requireFragmentManager(), pendingRecipients);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package org.thoughtcrime.securesms.conversation;
|
package org.thoughtcrime.securesms.conversation;
|
||||||
|
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
@ -16,13 +17,13 @@ import com.annimon.stream.Stream;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||||
import org.thoughtcrime.securesms.groups.GroupId;
|
import org.thoughtcrime.securesms.groups.GroupId;
|
||||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil;
|
||||||
import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient;
|
import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient;
|
||||||
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil;
|
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
@ -37,15 +38,19 @@ import java.io.IOException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
final class ConversationGroupViewModel extends ViewModel {
|
final class ConversationGroupViewModel extends ViewModel {
|
||||||
|
|
||||||
|
private static final long GV1_MIGRATION_REMINDER_INTERVAL = TimeUnit.DAYS.toMillis(1);
|
||||||
|
|
||||||
private final MutableLiveData<Recipient> liveRecipient;
|
private final MutableLiveData<Recipient> liveRecipient;
|
||||||
private final LiveData<GroupActiveState> groupActiveState;
|
private final LiveData<GroupActiveState> groupActiveState;
|
||||||
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
|
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
|
||||||
private final LiveData<Integer> actionableRequestingMembers;
|
private final LiveData<Integer> actionableRequestingMembers;
|
||||||
private final LiveData<ReviewState> reviewState;
|
private final LiveData<ReviewState> reviewState;
|
||||||
private final LiveData<List<RecipientId>> gv1MigrationSuggestions;
|
private final LiveData<List<RecipientId>> gv1MigrationSuggestions;
|
||||||
|
private final LiveData<Boolean> gv1MigrationReminder;
|
||||||
|
|
||||||
private ConversationGroupViewModel() {
|
private ConversationGroupViewModel() {
|
||||||
this.liveRecipient = new MutableLiveData<>();
|
this.liveRecipient = new MutableLiveData<>();
|
||||||
|
@ -65,6 +70,7 @@ final class ConversationGroupViewModel extends ViewModel {
|
||||||
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
|
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
|
||||||
this.actionableRequestingMembers = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToActionableRequestingMemberCount));
|
this.actionableRequestingMembers = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToActionableRequestingMemberCount));
|
||||||
this.gv1MigrationSuggestions = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(groupRecord, ConversationGroupViewModel::mapToGroupV1MigrationSuggestions));
|
this.gv1MigrationSuggestions = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(groupRecord, ConversationGroupViewModel::mapToGroupV1MigrationSuggestions));
|
||||||
|
this.gv1MigrationReminder = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(groupRecord, ConversationGroupViewModel::mapToGroupV1MigrationReminder));
|
||||||
this.reviewState = LiveDataUtil.combineLatest(groupRecord,
|
this.reviewState = LiveDataUtil.combineLatest(groupRecord,
|
||||||
duplicates,
|
duplicates,
|
||||||
(record, dups) -> dups.isEmpty()
|
(record, dups) -> dups.isEmpty()
|
||||||
|
@ -86,6 +92,13 @@ final class ConversationGroupViewModel extends ViewModel {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onMigrationInitiationReminderBannerDismissed(@NonNull RecipientId recipientId) {
|
||||||
|
SignalExecutors.BOUNDED.execute(() -> {
|
||||||
|
DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).markGroupsV1MigrationReminderSeen(recipientId, System.currentTimeMillis());
|
||||||
|
liveRecipient.postValue(liveRecipient.getValue());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number of pending group join requests that can be actioned by this client.
|
* The number of pending group join requests that can be actioned by this client.
|
||||||
*/
|
*/
|
||||||
|
@ -109,6 +122,10 @@ final class ConversationGroupViewModel extends ViewModel {
|
||||||
return gv1MigrationSuggestions;
|
return gv1MigrationSuggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull LiveData<Boolean> getShowGroupsV1MigrationBanner() {
|
||||||
|
return gv1MigrationReminder;
|
||||||
|
}
|
||||||
|
|
||||||
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
|
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
|
||||||
if (recipient != null && recipient.isGroup()) {
|
if (recipient != null && recipient.isGroup()) {
|
||||||
Application context = ApplicationDependencies.getApplication();
|
Application context = ApplicationDependencies.getApplication();
|
||||||
|
@ -156,15 +173,30 @@ final class ConversationGroupViewModel extends ViewModel {
|
||||||
Set<RecipientId> difference = SetUtil.difference(record.getFormerV1Members(), record.getMembers());
|
Set<RecipientId> difference = SetUtil.difference(record.getFormerV1Members(), record.getMembers());
|
||||||
|
|
||||||
return Stream.of(Recipient.resolvedList(difference))
|
return Stream.of(Recipient.resolvedList(difference))
|
||||||
.filter(r -> r.hasUuid() &&
|
.filter(GroupsV1MigrationUtil::isAutoMigratable)
|
||||||
r.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED &&
|
|
||||||
r.getGroupsV2Capability() == Recipient.Capability.SUPPORTED &&
|
|
||||||
r.getProfileKey() != null &&
|
|
||||||
r.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED)
|
|
||||||
.map(Recipient::getId)
|
.map(Recipient::getId)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private static boolean mapToGroupV1MigrationReminder(@Nullable GroupRecord record) {
|
||||||
|
if (record == null || !record.isV1Group() || !record.isActive() || !FeatureFlags.groupsV1ManualMigration()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean canAutoMigrate = Stream.of(Recipient.resolvedList(record.getMembers()))
|
||||||
|
.allMatch(GroupsV1MigrationUtil::isAutoMigratable);
|
||||||
|
|
||||||
|
if (canAutoMigrate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context context = ApplicationDependencies.getApplication();
|
||||||
|
long lastReminderTime = DatabaseFactory.getRecipientDatabase(context).getGroupsV1MigrationReminderLastSeen(record.getRecipientId());
|
||||||
|
|
||||||
|
return System.currentTimeMillis() - lastReminderTime > GV1_MIGRATION_REMINDER_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
public static void onCancelJoinRequest(@NonNull Recipient recipient,
|
public static void onCancelJoinRequest(@NonNull Recipient recipient,
|
||||||
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> callback)
|
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> callback)
|
||||||
{
|
{
|
||||||
|
|
|
@ -207,7 +207,7 @@ public class MmsSmsDatabase extends Database {
|
||||||
|
|
||||||
public Cursor getConversationSnippet(long threadId) {
|
public Cursor getConversationSnippet(long threadId) {
|
||||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND (" + SmsDatabase.TYPE + " IS NULL OR " + SmsDatabase.TYPE + " != " + SmsDatabase.Types.PROFILE_CHANGE_TYPE + ")";
|
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND (" + SmsDatabase.TYPE + " IS NULL OR " + SmsDatabase.TYPE + " NOT IN (" + SmsDatabase.Types.PROFILE_CHANGE_TYPE + ", " + SmsDatabase.Types.GV1_MIGRATION_TYPE + "))";
|
||||||
|
|
||||||
return queryTables(PROJECTION, selection, order, "1");
|
return queryTables(PROJECTION, selection, order, "1");
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,47 +86,48 @@ public class RecipientDatabase extends Database {
|
||||||
|
|
||||||
private static final String TAG = RecipientDatabase.class.getSimpleName();
|
private static final String TAG = RecipientDatabase.class.getSimpleName();
|
||||||
|
|
||||||
static final String TABLE_NAME = "recipient";
|
static final String TABLE_NAME = "recipient";
|
||||||
public static final String ID = "_id";
|
public static final String ID = "_id";
|
||||||
private static final String UUID = "uuid";
|
private static final String UUID = "uuid";
|
||||||
private static final String USERNAME = "username";
|
private static final String USERNAME = "username";
|
||||||
public static final String PHONE = "phone";
|
public static final String PHONE = "phone";
|
||||||
public static final String EMAIL = "email";
|
public static final String EMAIL = "email";
|
||||||
static final String GROUP_ID = "group_id";
|
static final String GROUP_ID = "group_id";
|
||||||
private static final String GROUP_TYPE = "group_type";
|
private static final String GROUP_TYPE = "group_type";
|
||||||
private static final String BLOCKED = "blocked";
|
private static final String BLOCKED = "blocked";
|
||||||
private static final String MESSAGE_RINGTONE = "message_ringtone";
|
private static final String MESSAGE_RINGTONE = "message_ringtone";
|
||||||
private static final String MESSAGE_VIBRATE = "message_vibrate";
|
private static final String MESSAGE_VIBRATE = "message_vibrate";
|
||||||
private static final String CALL_RINGTONE = "call_ringtone";
|
private static final String CALL_RINGTONE = "call_ringtone";
|
||||||
private static final String CALL_VIBRATE = "call_vibrate";
|
private static final String CALL_VIBRATE = "call_vibrate";
|
||||||
private static final String NOTIFICATION_CHANNEL = "notification_channel";
|
private static final String NOTIFICATION_CHANNEL = "notification_channel";
|
||||||
private static final String MUTE_UNTIL = "mute_until";
|
private static final String MUTE_UNTIL = "mute_until";
|
||||||
private static final String COLOR = "color";
|
private static final String COLOR = "color";
|
||||||
private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder";
|
private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder";
|
||||||
private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id";
|
private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id";
|
||||||
private static final String MESSAGE_EXPIRATION_TIME = "message_expiration_time";
|
private static final String MESSAGE_EXPIRATION_TIME = "message_expiration_time";
|
||||||
public static final String REGISTERED = "registered";
|
public static final String REGISTERED = "registered";
|
||||||
public static final String SYSTEM_DISPLAY_NAME = "system_display_name";
|
public static final String SYSTEM_DISPLAY_NAME = "system_display_name";
|
||||||
private static final String SYSTEM_PHOTO_URI = "system_photo_uri";
|
private static final String SYSTEM_PHOTO_URI = "system_photo_uri";
|
||||||
public static final String SYSTEM_PHONE_TYPE = "system_phone_type";
|
public static final String SYSTEM_PHONE_TYPE = "system_phone_type";
|
||||||
public static final String SYSTEM_PHONE_LABEL = "system_phone_label";
|
public static final String SYSTEM_PHONE_LABEL = "system_phone_label";
|
||||||
private static final String SYSTEM_CONTACT_URI = "system_contact_uri";
|
private static final String SYSTEM_CONTACT_URI = "system_contact_uri";
|
||||||
private static final String SYSTEM_INFO_PENDING = "system_info_pending";
|
private static final String SYSTEM_INFO_PENDING = "system_info_pending";
|
||||||
private static final String PROFILE_KEY = "profile_key";
|
private static final String PROFILE_KEY = "profile_key";
|
||||||
private static final String PROFILE_KEY_CREDENTIAL = "profile_key_credential";
|
private static final String PROFILE_KEY_CREDENTIAL = "profile_key_credential";
|
||||||
private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar";
|
private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar";
|
||||||
private static final String PROFILE_SHARING = "profile_sharing";
|
private static final String PROFILE_SHARING = "profile_sharing";
|
||||||
private static final String LAST_PROFILE_FETCH = "last_profile_fetch";
|
private static final String LAST_PROFILE_FETCH = "last_profile_fetch";
|
||||||
private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
|
private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
|
||||||
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
|
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
|
||||||
private static final String CAPABILITIES = "capabilities";
|
private static final String CAPABILITIES = "capabilities";
|
||||||
private static final String STORAGE_SERVICE_ID = "storage_service_key";
|
private static final String STORAGE_SERVICE_ID = "storage_service_key";
|
||||||
private static final String DIRTY = "dirty";
|
private static final String DIRTY = "dirty";
|
||||||
private static final String PROFILE_GIVEN_NAME = "signal_profile_name";
|
private static final String PROFILE_GIVEN_NAME = "signal_profile_name";
|
||||||
private static final String PROFILE_FAMILY_NAME = "profile_family_name";
|
private static final String PROFILE_FAMILY_NAME = "profile_family_name";
|
||||||
private static final String PROFILE_JOINED_NAME = "profile_joined_name";
|
private static final String PROFILE_JOINED_NAME = "profile_joined_name";
|
||||||
private static final String MENTION_SETTING = "mention_setting";
|
private static final String MENTION_SETTING = "mention_setting";
|
||||||
private static final String STORAGE_PROTO = "storage_proto";
|
private static final String STORAGE_PROTO = "storage_proto";
|
||||||
|
private static final String LAST_GV1_MIGRATE_REMINDER = "last_gv1_migrate_reminder";
|
||||||
|
|
||||||
public static final String SEARCH_PROFILE_NAME = "search_signal_profile";
|
public static final String SEARCH_PROFILE_NAME = "search_signal_profile";
|
||||||
private static final String SORT_NAME = "sort_name";
|
private static final String SORT_NAME = "sort_name";
|
||||||
|
@ -305,46 +306,47 @@ public class RecipientDatabase extends Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static final String CREATE_TABLE =
|
public static final String CREATE_TABLE =
|
||||||
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||||
UUID + " TEXT UNIQUE DEFAULT NULL, " +
|
UUID + " TEXT UNIQUE DEFAULT NULL, " +
|
||||||
USERNAME + " TEXT UNIQUE DEFAULT NULL, " +
|
USERNAME + " TEXT UNIQUE DEFAULT NULL, " +
|
||||||
PHONE + " TEXT UNIQUE DEFAULT NULL, " +
|
PHONE + " TEXT UNIQUE DEFAULT NULL, " +
|
||||||
EMAIL + " TEXT UNIQUE DEFAULT NULL, " +
|
EMAIL + " TEXT UNIQUE DEFAULT NULL, " +
|
||||||
GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " +
|
GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " +
|
||||||
GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " +
|
GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " +
|
||||||
BLOCKED + " INTEGER DEFAULT 0," +
|
BLOCKED + " INTEGER DEFAULT 0," +
|
||||||
MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " +
|
MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " +
|
||||||
MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
|
MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
|
||||||
CALL_RINGTONE + " TEXT DEFAULT NULL, " +
|
CALL_RINGTONE + " TEXT DEFAULT NULL, " +
|
||||||
CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
|
CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
|
||||||
NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " +
|
NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " +
|
||||||
MUTE_UNTIL + " INTEGER DEFAULT 0, " +
|
MUTE_UNTIL + " INTEGER DEFAULT 0, " +
|
||||||
COLOR + " TEXT DEFAULT NULL, " +
|
COLOR + " TEXT DEFAULT NULL, " +
|
||||||
SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " +
|
SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " +
|
||||||
DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
|
DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
|
||||||
MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " +
|
MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " +
|
||||||
REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " +
|
REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " +
|
||||||
SYSTEM_DISPLAY_NAME + " TEXT DEFAULT NULL, " +
|
SYSTEM_DISPLAY_NAME + " TEXT DEFAULT NULL, " +
|
||||||
SYSTEM_PHOTO_URI + " TEXT DEFAULT NULL, " +
|
SYSTEM_PHOTO_URI + " TEXT DEFAULT NULL, " +
|
||||||
SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " +
|
SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " +
|
||||||
SYSTEM_PHONE_TYPE + " INTEGER DEFAULT -1, " +
|
SYSTEM_PHONE_TYPE + " INTEGER DEFAULT -1, " +
|
||||||
SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " +
|
SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " +
|
||||||
SYSTEM_INFO_PENDING + " INTEGER DEFAULT 0, " +
|
SYSTEM_INFO_PENDING + " INTEGER DEFAULT 0, " +
|
||||||
PROFILE_KEY + " TEXT DEFAULT NULL, " +
|
PROFILE_KEY + " TEXT DEFAULT NULL, " +
|
||||||
PROFILE_KEY_CREDENTIAL + " TEXT DEFAULT NULL, " +
|
PROFILE_KEY_CREDENTIAL + " TEXT DEFAULT NULL, " +
|
||||||
PROFILE_GIVEN_NAME + " TEXT DEFAULT NULL, " +
|
PROFILE_GIVEN_NAME + " TEXT DEFAULT NULL, " +
|
||||||
PROFILE_FAMILY_NAME + " TEXT DEFAULT NULL, " +
|
PROFILE_FAMILY_NAME + " TEXT DEFAULT NULL, " +
|
||||||
PROFILE_JOINED_NAME + " TEXT DEFAULT NULL, " +
|
PROFILE_JOINED_NAME + " TEXT DEFAULT NULL, " +
|
||||||
SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " +
|
SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " +
|
||||||
PROFILE_SHARING + " INTEGER DEFAULT 0, " +
|
PROFILE_SHARING + " INTEGER DEFAULT 0, " +
|
||||||
LAST_PROFILE_FETCH + " INTEGER DEFAULT 0, " +
|
LAST_PROFILE_FETCH + " INTEGER DEFAULT 0, " +
|
||||||
UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " +
|
UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " +
|
||||||
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " +
|
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " +
|
||||||
STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " +
|
STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " +
|
||||||
DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " +
|
DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " +
|
||||||
MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.ALWAYS_NOTIFY.getId() + ", " +
|
MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.ALWAYS_NOTIFY.getId() + ", " +
|
||||||
STORAGE_PROTO + " TEXT DEFAULT NULL, " +
|
STORAGE_PROTO + " TEXT DEFAULT NULL, " +
|
||||||
CAPABILITIES + " INTEGER DEFAULT 0);";
|
CAPABILITIES + " INTEGER DEFAULT 0, " +
|
||||||
|
LAST_GV1_MIGRATE_REMINDER + " INTEGER DEFAULT 0);";
|
||||||
|
|
||||||
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
|
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
|
||||||
" FROM " + TABLE_NAME +
|
" FROM " + TABLE_NAME +
|
||||||
|
@ -1479,6 +1481,26 @@ public class RecipientDatabase extends Database {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void markGroupsV1MigrationReminderSeen(@NonNull RecipientId id, long time) {
|
||||||
|
ContentValues values = new ContentValues(1);
|
||||||
|
values.put(LAST_GV1_MIGRATE_REMINDER, time);
|
||||||
|
if (update(id, values)) {
|
||||||
|
Recipient.live(id).refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getGroupsV1MigrationReminderLastSeen(@NonNull RecipientId id) {
|
||||||
|
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||||
|
|
||||||
|
try (Cursor cursor = db.query(TABLE_NAME, new String[] { LAST_GV1_MIGRATE_REMINDER }, ID_WHERE, SqlUtil.buildArgs(id), null, null, null)) {
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
return CursorUtil.requireLong(cursor, LAST_GV1_MIGRATE_REMINDER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
public void setCapabilities(@NonNull RecipientId id, @NonNull SignalServiceProfile.Capabilities capabilities) {
|
public void setCapabilities(@NonNull RecipientId id, @NonNull SignalServiceProfile.Capabilities capabilities) {
|
||||||
long value = 0;
|
long value = 0;
|
||||||
|
|
||||||
|
|
|
@ -222,7 +222,7 @@ public class ThreadDatabase extends Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) {
|
public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) {
|
||||||
if (MmsSmsColumns.Types.isProfileChange(type)) {
|
if (isSilentType(type)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1370,6 +1370,11 @@ public class ThreadDatabase extends Database {
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSilentType(long type) {
|
||||||
|
return MmsSmsColumns.Types.isProfileChange(type) ||
|
||||||
|
MmsSmsColumns.Types.isGroupV1MigrationEvent(type);
|
||||||
|
}
|
||||||
|
|
||||||
public Reader readerFor(Cursor cursor) {
|
public Reader readerFor(Cursor cursor) {
|
||||||
return new Reader(cursor);
|
return new Reader(cursor);
|
||||||
}
|
}
|
||||||
|
|
|
@ -159,8 +159,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||||
private static final int CAPABILITIES_REFACTOR = 79;
|
private static final int CAPABILITIES_REFACTOR = 79;
|
||||||
private static final int GV1_MIGRATION = 80;
|
private static final int GV1_MIGRATION = 80;
|
||||||
private static final int NOTIFIED_TIMESTAMP = 81;
|
private static final int NOTIFIED_TIMESTAMP = 81;
|
||||||
|
private static final int GV1_MIGRATION_LAST_SEEN = 82;
|
||||||
|
|
||||||
private static final int DATABASE_VERSION = 81;
|
private static final int DATABASE_VERSION = 82;
|
||||||
private static final String DATABASE_NAME = "signal.db";
|
private static final String DATABASE_NAME = "signal.db";
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
|
@ -1165,6 +1166,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||||
db.execSQL("ALTER TABLE mms ADD COLUMN notified_timestamp INTEGER DEFAULT 0");
|
db.execSQL("ALTER TABLE mms ADD COLUMN notified_timestamp INTEGER DEFAULT 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < GV1_MIGRATION_LAST_SEEN) {
|
||||||
|
db.execSQL("ALTER TABLE recipient ADD COLUMN last_gv1_migrate_reminder INTEGER DEFAULT 0");
|
||||||
|
}
|
||||||
|
|
||||||
db.setTransactionSuccessful();
|
db.setTransactionSuccessful();
|
||||||
} finally {
|
} finally {
|
||||||
db.endTransaction();
|
db.endTransaction();
|
||||||
|
|
|
@ -175,9 +175,8 @@ public abstract class GroupId {
|
||||||
return encodedId.hashCode();
|
return encodedId.hashCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public @NonNull String toString() {
|
||||||
return encodedId;
|
return encodedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,244 @@
|
||||||
|
package org.thoughtcrime.securesms.groups;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.annimon.stream.Stream;
|
||||||
|
|
||||||
|
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||||
|
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
|
import org.thoughtcrime.securesms.mms.MmsException;
|
||||||
|
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||||
|
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||||
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
|
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||||
|
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||||
|
|
||||||
|
import java.io.Closeable;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.LATEST;
|
||||||
|
|
||||||
|
public final class GroupsV1MigrationUtil {
|
||||||
|
|
||||||
|
private static final String TAG = Log.tag(GroupsV1MigrationUtil.class);
|
||||||
|
|
||||||
|
private GroupsV1MigrationUtil() {}
|
||||||
|
|
||||||
|
public static void migrate(@NonNull Context context, @NonNull RecipientId recipientId, boolean forced)
|
||||||
|
throws IOException, RetryLaterException, GroupChangeBusyException, InvalidMigrationStateException
|
||||||
|
{
|
||||||
|
Recipient groupRecipient = Recipient.resolved(recipientId);
|
||||||
|
Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId);
|
||||||
|
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||||
|
|
||||||
|
if (threadId == null) {
|
||||||
|
Log.w(TAG, "No thread found!");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupRecipient.isPushV1Group()) {
|
||||||
|
Log.w(TAG, "Not a V1 group!");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupRecipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) {
|
||||||
|
Log.w(TAG, "Too many members! Size: " + groupRecipient.getParticipants().size());
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupId.V1 gv1Id = groupRecipient.requireGroupId().requireV1();
|
||||||
|
GroupId.V2 gv2Id = gv1Id.deriveV2MigrationGroupId();
|
||||||
|
GroupMasterKey gv2MasterKey = gv1Id.deriveV2MigrationMasterKey();
|
||||||
|
boolean newlyCreated = false;
|
||||||
|
|
||||||
|
if (groupDatabase.groupExists(gv2Id)) {
|
||||||
|
Log.w(TAG, "We already have a V2 group for this V1 group! Must have been added before we were migration-capable.");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupRecipient.isActiveGroup()) {
|
||||||
|
Log.w(TAG, "Group is inactive! Can't migrate.");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (GroupManager.v2GroupStatus(context, gv2MasterKey)) {
|
||||||
|
case DOES_NOT_EXIST:
|
||||||
|
Log.i(TAG, "Group does not exist on the service.");
|
||||||
|
|
||||||
|
if (!groupRecipient.isProfileSharing()) {
|
||||||
|
Log.w(TAG, "Profile sharing is disabled! Can't migrate.");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forced && SignalStore.internalValues().disableGv1AutoMigrateInitiation()) {
|
||||||
|
Log.w(TAG, "Auto migration initiation has been disabled! Skipping.");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forced && !FeatureFlags.groupsV1AutoMigration()) {
|
||||||
|
Log.w(TAG, "Auto migration is not enabled! Skipping.");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forced && !FeatureFlags.groupsV1ManualMigration()) {
|
||||||
|
Log.w(TAG, "Manual migration is not enabled! Skipping.");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
RecipientUtil.ensureUuidsAreAvailable(context, groupRecipient.getParticipants());
|
||||||
|
groupRecipient = groupRecipient.fresh();
|
||||||
|
|
||||||
|
List<Recipient> registeredMembers = RecipientUtil.getEligibleForSending(groupRecipient.getParticipants());
|
||||||
|
List<Recipient> possibleMembers = forced ? getMigratableManualMigrationMembers(registeredMembers)
|
||||||
|
: getMigratableAutoMigrationMembers(registeredMembers);
|
||||||
|
|
||||||
|
if (!forced && possibleMembers.size() != registeredMembers.size()) {
|
||||||
|
Log.w(TAG, "Not allowed to invite or leave registered users behind in an auto-migration! Skipping.");
|
||||||
|
throw new InvalidMigrationStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Attempting to create group.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
GroupManager.migrateGroupToServer(context, gv1Id, possibleMembers);
|
||||||
|
newlyCreated = true;
|
||||||
|
Log.i(TAG, "Successfully created!");
|
||||||
|
} catch (GroupChangeFailedException e) {
|
||||||
|
Log.w(TAG, "Failed to migrate group. Retrying.", e);
|
||||||
|
throw new RetryLaterException();
|
||||||
|
} catch (MembershipNotSuitableForV2Exception e) {
|
||||||
|
Log.w(TAG, "Failed to migrate job due to the membership not yet being suitable for GV2. Aborting.", e);
|
||||||
|
return;
|
||||||
|
} catch (GroupAlreadyExistsException e) {
|
||||||
|
Log.w(TAG, "Someone else created the group while we were trying to do the same! It exists now. Continuing on.", e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case NOT_A_MEMBER:
|
||||||
|
Log.w(TAG, "The migrated group already exists, but we are not a member. Doing a local leave.");
|
||||||
|
handleLeftBehind(context, gv1Id, groupRecipient, threadId);
|
||||||
|
return;
|
||||||
|
case FULL_OR_PENDING_MEMBER:
|
||||||
|
Log.w(TAG, "The migrated group already exists, and we're in it. Continuing on.");
|
||||||
|
break;
|
||||||
|
default: throw new AssertionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Migrating local group " + gv1Id + " to " + gv2Id);
|
||||||
|
|
||||||
|
DecryptedGroup decryptedGroup = performLocalMigration(context, gv1Id, threadId, groupRecipient);
|
||||||
|
|
||||||
|
if (newlyCreated && decryptedGroup != null && !SignalStore.internalValues().disableGv1AutoMigrateNotification()) {
|
||||||
|
GroupManager.sendNoopUpdate(context, gv2MasterKey, decryptedGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void performLocalMigration(@NonNull Context context, @NonNull GroupId.V1 gv1Id) throws IOException
|
||||||
|
{
|
||||||
|
try (Closeable ignored = GroupsV2ProcessingLock.acquireGroupProcessingLock()) {
|
||||||
|
Recipient recipient = Recipient.externalGroupExact(context, gv1Id);
|
||||||
|
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
|
||||||
|
|
||||||
|
performLocalMigration(context, gv1Id, threadId, recipient);
|
||||||
|
} catch (GroupChangeBusyException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @Nullable DecryptedGroup performLocalMigration(@NonNull Context context,
|
||||||
|
@NonNull GroupId.V1 gv1Id,
|
||||||
|
long threadId,
|
||||||
|
@NonNull Recipient groupRecipient)
|
||||||
|
throws IOException, GroupChangeBusyException
|
||||||
|
{
|
||||||
|
try (Closeable ignored = GroupsV2ProcessingLock.acquireGroupProcessingLock()){
|
||||||
|
DecryptedGroup decryptedGroup;
|
||||||
|
try {
|
||||||
|
decryptedGroup = GroupManager.addedGroupVersion(context, gv1Id.deriveV2MigrationMasterKey());
|
||||||
|
} catch (GroupDoesNotExistException e) {
|
||||||
|
throw new IOException("[Local] The group should exist already!");
|
||||||
|
} catch (GroupNotAMemberException e) {
|
||||||
|
Log.w(TAG, "[Local] We are not in the group. Doing a local leave.");
|
||||||
|
handleLeftBehind(context, gv1Id, groupRecipient, threadId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RecipientId> pendingRecipients = Stream.of(DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList()))
|
||||||
|
.map(uuid -> Recipient.externalPush(context, uuid, null, false))
|
||||||
|
.filterNot(Recipient::isSelf)
|
||||||
|
.map(Recipient::getId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Log.i(TAG, "[Local] Migrating group over to the version we were added to: V" + decryptedGroup.getRevision());
|
||||||
|
DatabaseFactory.getGroupDatabase(context).migrateToV2(gv1Id, decryptedGroup);
|
||||||
|
DatabaseFactory.getSmsDatabase(context).insertGroupV1MigrationEvents(groupRecipient.getId(), threadId, pendingRecipients);
|
||||||
|
|
||||||
|
Log.i(TAG, "[Local] Applying all changes since V" + decryptedGroup.getRevision());
|
||||||
|
try {
|
||||||
|
GroupManager.updateGroupFromServer(context, gv1Id.deriveV2MigrationMasterKey(), LATEST, System.currentTimeMillis(), null);
|
||||||
|
} catch (GroupChangeBusyException | GroupNotAMemberException e) {
|
||||||
|
Log.w(TAG, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decryptedGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleLeftBehind(@NonNull Context context, @NonNull GroupId.V1 gv1Id, @NonNull Recipient groupRecipient, long threadId) {
|
||||||
|
OutgoingMediaMessage leaveMessage = GroupUtil.createGroupV1LeaveMessage(gv1Id, groupRecipient);
|
||||||
|
try {
|
||||||
|
long id = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(leaveMessage, threadId, false, null);
|
||||||
|
DatabaseFactory.getMmsDatabase(context).markAsSent(id, true);
|
||||||
|
} catch (MmsException e) {
|
||||||
|
Log.w(TAG, "Failed to insert group leave message!", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseFactory.getGroupDatabase(context).setActive(gv1Id, false);
|
||||||
|
DatabaseFactory.getGroupDatabase(context).remove(gv1Id, Recipient.self().getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In addition to meeting traditional requirements, you must also have a profile key for a member
|
||||||
|
* to consider them migratable in an auto-migration.
|
||||||
|
*/
|
||||||
|
private static @NonNull List<Recipient> getMigratableAutoMigrationMembers(@NonNull List<Recipient> registeredMembers) {
|
||||||
|
return Stream.of(getMigratableManualMigrationMembers(registeredMembers))
|
||||||
|
.filter(r -> r.getProfileKey() != null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can only migrate users that have the required capabilities.
|
||||||
|
*/
|
||||||
|
private static @NonNull List<Recipient> getMigratableManualMigrationMembers(@NonNull List<Recipient> registeredMembers) {
|
||||||
|
return Stream.of(registeredMembers)
|
||||||
|
.filter(r -> r.getGroupsV2Capability() == Recipient.Capability.SUPPORTED &&
|
||||||
|
r.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the user meets all the requirements to be auto-migrated, otherwise false.
|
||||||
|
*/
|
||||||
|
public static boolean isAutoMigratable(@NonNull Recipient recipient) {
|
||||||
|
return recipient.hasUuid() &&
|
||||||
|
recipient.getGroupsV2Capability() == Recipient.Capability.SUPPORTED &&
|
||||||
|
recipient.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED &&
|
||||||
|
recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED &&
|
||||||
|
recipient.getProfileKey() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class InvalidMigrationStateException extends Exception {
|
||||||
|
}
|
||||||
|
}
|
|
@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndR
|
||||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog;
|
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.GroupRightsDialog;
|
||||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment;
|
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment;
|
||||||
|
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment;
|
||||||
import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity;
|
import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
|
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
|
||||||
|
@ -384,6 +385,17 @@ public class ManageGroupFragment extends LoggingFragment {
|
||||||
groupInfoText.setLearnMoreVisible(true);
|
groupInfoText.setLearnMoreVisible(true);
|
||||||
groupInfoText.setVisibility(View.VISIBLE);
|
groupInfoText.setVisibility(View.VISIBLE);
|
||||||
break;
|
break;
|
||||||
|
case LEGACY_GROUP_UPGRADE:
|
||||||
|
groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade);
|
||||||
|
groupInfoText.setOnLinkClickListener(v -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(requireFragmentManager(), Recipient.externalPossiblyMigratedGroup(requireContext(), groupId).getId()));
|
||||||
|
groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group);
|
||||||
|
groupInfoText.setVisibility(View.VISIBLE);
|
||||||
|
break;
|
||||||
|
case LEGACY_GROUP_TOO_LARGE:
|
||||||
|
groupInfoText.setText(context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().getHardLimit() - 1));
|
||||||
|
groupInfoText.setLearnMoreVisible(false);
|
||||||
|
groupInfoText.setVisibility(View.VISIBLE);
|
||||||
|
break;
|
||||||
case MMS_WARNING:
|
case MMS_WARNING:
|
||||||
groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group);
|
groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group);
|
||||||
groupInfoText.setOnLinkClickListener(v -> startActivity(new Intent(requireContext(), InviteActivity.class)));
|
groupInfoText.setOnLinkClickListener(v -> startActivity(new Intent(requireContext(), InviteActivity.class)));
|
||||||
|
|
|
@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||||
|
@ -122,9 +123,15 @@ public class ManageGroupViewModel extends ViewModel {
|
||||||
this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient,
|
this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient,
|
||||||
recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting())));
|
recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting())));
|
||||||
this.groupLinkOn = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::isEnabled);
|
this.groupLinkOn = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::isEnabled);
|
||||||
this.groupInfoMessage = Transformations.map(this.showLegacyIndicator,
|
this.groupInfoMessage = Transformations.map(this.groupRecipient,
|
||||||
showLegacyInfo -> {
|
recipient -> {
|
||||||
if (showLegacyInfo) {
|
boolean showLegacyInfo = recipient.requireGroupId().isV1();
|
||||||
|
|
||||||
|
if (showLegacyInfo && FeatureFlags.groupsV1ManualMigration() && recipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) {
|
||||||
|
return GroupInfoMessage.LEGACY_GROUP_TOO_LARGE;
|
||||||
|
} else if (showLegacyInfo && FeatureFlags.groupsV1ManualMigration()) {
|
||||||
|
return GroupInfoMessage.LEGACY_GROUP_UPGRADE;
|
||||||
|
} else if (showLegacyInfo) {
|
||||||
return GroupInfoMessage.LEGACY_GROUP_LEARN_MORE;
|
return GroupInfoMessage.LEGACY_GROUP_LEARN_MORE;
|
||||||
} else if (groupId.isMms()) {
|
} else if (groupId.isMms()) {
|
||||||
return GroupInfoMessage.MMS_WARNING;
|
return GroupInfoMessage.MMS_WARNING;
|
||||||
|
@ -393,6 +400,8 @@ public class ManageGroupViewModel extends ViewModel {
|
||||||
enum GroupInfoMessage {
|
enum GroupInfoMessage {
|
||||||
NONE,
|
NONE,
|
||||||
LEGACY_GROUP_LEARN_MORE,
|
LEGACY_GROUP_LEARN_MORE,
|
||||||
|
LEGACY_GROUP_UPGRADE,
|
||||||
|
LEGACY_GROUP_TOO_LARGE,
|
||||||
MMS_WARNING
|
MMS_WARNING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,19 +24,24 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class GroupsV1MigrationBottomSheetDialogFragment extends BottomSheetDialogFragment {
|
/**
|
||||||
|
* Shows more info about a GV1->GV2 migration event. Looks similar to
|
||||||
|
* {@link GroupsV1MigrationInitiationBottomSheetDialogFragment}, but only displays static data.
|
||||||
|
*/
|
||||||
|
public final class GroupsV1MigrationInfoBottomSheetDialogFragment extends BottomSheetDialogFragment {
|
||||||
|
|
||||||
private static final String KEY_PENDING = "pending";
|
private static final String KEY_PENDING = "pending";
|
||||||
|
|
||||||
private GroupsV1MigrationViewModel viewModel;
|
private GroupsV1MigrationInfoViewModel viewModel;
|
||||||
private GroupMemberListView pendingList;
|
private GroupMemberListView pendingList;
|
||||||
private TextView pendingTitle;
|
private TextView pendingTitle;
|
||||||
|
private View pendingContainer;
|
||||||
|
|
||||||
public static void showForLearnMore(@NonNull FragmentManager manager, @NonNull List<RecipientId> pendingRecipients) {
|
public static void showForLearnMore(@NonNull FragmentManager manager, @NonNull List<RecipientId> pendingRecipients) {
|
||||||
Bundle args = new Bundle();
|
Bundle args = new Bundle();
|
||||||
args.putParcelableArrayList(KEY_PENDING, new ArrayList<>(pendingRecipients));
|
args.putParcelableArrayList(KEY_PENDING, new ArrayList<>(pendingRecipients));
|
||||||
|
|
||||||
GroupsV1MigrationBottomSheetDialogFragment fragment = new GroupsV1MigrationBottomSheetDialogFragment();
|
GroupsV1MigrationInfoBottomSheetDialogFragment fragment = new GroupsV1MigrationInfoBottomSheetDialogFragment();
|
||||||
fragment.setArguments(args);
|
fragment.setArguments(args);
|
||||||
|
|
||||||
fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
|
fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
|
||||||
|
@ -58,13 +63,17 @@ public final class GroupsV1MigrationBottomSheetDialogFragment extends BottomShee
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
this.pendingTitle = view.findViewById(R.id.gv1_learn_more_pending_title);
|
this.pendingContainer = view.findViewById(R.id.gv1_learn_more_pending_container);
|
||||||
this.pendingList = view.findViewById(R.id.gv1_learn_more_pending_list);
|
this.pendingTitle = view.findViewById(R.id.gv1_learn_more_pending_title);
|
||||||
|
this.pendingList = view.findViewById(R.id.gv1_learn_more_pending_list);
|
||||||
|
|
||||||
List<RecipientId> pending = getArguments().containsKey(KEY_PENDING) ? getArguments().getParcelableArrayList(KEY_PENDING) : null;
|
//noinspection ConstantConditions
|
||||||
|
List<RecipientId> pending = getArguments().getParcelableArrayList(KEY_PENDING);
|
||||||
|
|
||||||
this.viewModel = ViewModelProviders.of(this, new GroupsV1MigrationViewModel.Factory(pending)).get(GroupsV1MigrationViewModel.class);
|
this.viewModel = ViewModelProviders.of(this, new GroupsV1MigrationInfoViewModel.Factory(pending)).get(GroupsV1MigrationInfoViewModel.class);
|
||||||
viewModel.getPendingMembers().observe(getViewLifecycleOwner(), this::onPendingMembersChanged);
|
viewModel.getPendingMembers().observe(getViewLifecycleOwner(), this::onPendingMembersChanged);
|
||||||
|
|
||||||
|
view.findViewById(R.id.gv1_learn_more_ok_button).setOnClickListener(v -> dismiss());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -73,7 +82,12 @@ public final class GroupsV1MigrationBottomSheetDialogFragment extends BottomShee
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onPendingMembersChanged(@NonNull List<Recipient> pendingMembers) {
|
private void onPendingMembersChanged(@NonNull List<Recipient> pendingMembers) {
|
||||||
pendingTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationLearnMore_these_members_will_need_to_accept_an_invite, pendingMembers.size()));
|
if (pendingMembers.size() > 0) {
|
||||||
pendingList.setDisplayOnlyMembers(pendingMembers);
|
pendingContainer.setVisibility(View.VISIBLE);
|
||||||
|
pendingTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationLearnMore_these_members_will_need_to_accept_an_invite, pendingMembers.size()));
|
||||||
|
pendingList.setDisplayOnlyMembers(pendingMembers);
|
||||||
|
} else {
|
||||||
|
pendingContainer.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,11 +12,11 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
class GroupsV1MigrationViewModel extends ViewModel {
|
class GroupsV1MigrationInfoViewModel extends ViewModel {
|
||||||
|
|
||||||
private final MutableLiveData<List<Recipient>> pendingMembers;
|
private final MutableLiveData<List<Recipient>> pendingMembers;
|
||||||
|
|
||||||
private GroupsV1MigrationViewModel(@NonNull List<RecipientId> pendingMembers) {
|
private GroupsV1MigrationInfoViewModel(@NonNull List<RecipientId> pendingMembers) {
|
||||||
this.pendingMembers = new MutableLiveData<>();
|
this.pendingMembers = new MutableLiveData<>();
|
||||||
|
|
||||||
SignalExecutors.BOUNDED.execute(() -> {
|
SignalExecutors.BOUNDED.execute(() -> {
|
||||||
|
@ -38,7 +38,7 @@ class GroupsV1MigrationViewModel extends ViewModel {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||||
return modelClass.cast(new GroupsV1MigrationViewModel(pendingMembers));
|
return modelClass.cast(new GroupsV1MigrationInfoViewModel(pendingMembers));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,133 @@
|
||||||
|
package org.thoughtcrime.securesms.groups.ui.migration;
|
||||||
|
|
||||||
|
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.app.AlertDialog;
|
||||||
|
import androidx.fragment.app.DialogFragment;
|
||||||
|
import androidx.fragment.app.FragmentManager;
|
||||||
|
import androidx.lifecycle.ViewModelProviders;
|
||||||
|
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.R;
|
||||||
|
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
|
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bottom sheet that allows a user to initiation a manual GV1->GV2 migration. Will show the user
|
||||||
|
* the members that will be invited/left behind.
|
||||||
|
*/
|
||||||
|
public final class GroupsV1MigrationInitiationBottomSheetDialogFragment extends BottomSheetDialogFragment {
|
||||||
|
|
||||||
|
private static final String KEY_GROUP_RECIPIENT_ID = "group_recipient_id";
|
||||||
|
|
||||||
|
private GroupsV1MigrationInitiationViewModel viewModel;
|
||||||
|
private GroupMemberListView inviteList;
|
||||||
|
private TextView inviteTitle;
|
||||||
|
private View inviteContainer;
|
||||||
|
private GroupMemberListView ineligibleList;
|
||||||
|
private TextView ineligibleTitle;
|
||||||
|
private View ineligibleContainer;
|
||||||
|
|
||||||
|
public static void showForInitiation(@NonNull FragmentManager manager, @NonNull RecipientId groupRecipientId) {
|
||||||
|
Bundle args = new Bundle();
|
||||||
|
args.putParcelable(KEY_GROUP_RECIPIENT_ID, groupRecipientId);
|
||||||
|
|
||||||
|
GroupsV1MigrationInitiationBottomSheetDialogFragment fragment = new GroupsV1MigrationInitiationBottomSheetDialogFragment();
|
||||||
|
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) {
|
||||||
|
return inflater.inflate(R.layout.groupsv1_migration_bottom_sheet, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
this.inviteContainer = view.findViewById(R.id.gv1_migrate_invite_container);
|
||||||
|
this.inviteTitle = view.findViewById(R.id.gv1_migrate_invite_title);
|
||||||
|
this.inviteList = view.findViewById(R.id.gv1_migrate_invite_list);
|
||||||
|
this.ineligibleContainer = view.findViewById(R.id.gv1_migrate_ineligible_container);
|
||||||
|
this.ineligibleTitle = view.findViewById(R.id.gv1_migrate_ineligible_title);
|
||||||
|
this.ineligibleList = view.findViewById(R.id.gv1_migrate_ineligible_list);
|
||||||
|
|
||||||
|
inviteList.setNestedScrollingEnabled(false);
|
||||||
|
ineligibleList.setNestedScrollingEnabled(false);
|
||||||
|
|
||||||
|
//noinspection ConstantConditions
|
||||||
|
RecipientId groupRecipientId = getArguments().getParcelable(KEY_GROUP_RECIPIENT_ID);
|
||||||
|
|
||||||
|
//noinspection ConstantConditions
|
||||||
|
viewModel = ViewModelProviders.of(this, new GroupsV1MigrationInitiationViewModel.Factory(groupRecipientId)).get(GroupsV1MigrationInitiationViewModel.class);
|
||||||
|
viewModel.getMigrationState().observe(getViewLifecycleOwner(), this::onMigrationStateChanged);
|
||||||
|
|
||||||
|
view.findViewById(R.id.gv1_migrate_cancel_button).setOnClickListener(v -> dismiss());
|
||||||
|
view.findViewById(R.id.gv1_migrate_upgrade_button).setOnClickListener(v -> onUpgradeClicked());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
|
||||||
|
BottomSheetUtil.show(manager, tag, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onMigrationStateChanged(@NonNull MigrationState migrationState) {
|
||||||
|
if (migrationState.getNeedsInvite().size() > 0) {
|
||||||
|
inviteContainer.setVisibility(View.VISIBLE);
|
||||||
|
inviteTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationInitiation_these_members_will_need_to_accept_an_invite, migrationState.getNeedsInvite().size()));
|
||||||
|
inviteList.setDisplayOnlyMembers(migrationState.getNeedsInvite());
|
||||||
|
} else {
|
||||||
|
inviteContainer.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrationState.getIneligible().size() > 0) {
|
||||||
|
ineligibleContainer.setVisibility(View.VISIBLE);
|
||||||
|
ineligibleTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationInitiation_these_members_are_not_capable_of_joining_new_groups, migrationState.getIneligible().size()));
|
||||||
|
ineligibleList.setDisplayOnlyMembers(migrationState.getIneligible());
|
||||||
|
} else {
|
||||||
|
ineligibleContainer.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onUpgradeClicked() {
|
||||||
|
AlertDialog dialog = SimpleProgressDialog.show(requireContext());
|
||||||
|
viewModel.onUpgradeClicked().observe(getViewLifecycleOwner(), result -> {
|
||||||
|
switch (result) {
|
||||||
|
case SUCCESS:
|
||||||
|
dismiss();
|
||||||
|
break;
|
||||||
|
case FAILURE_GENERAL:
|
||||||
|
Toast.makeText(requireContext(), R.string.GroupsV1MigrationInitiation_failed_to_upgrade, Toast.LENGTH_SHORT).show();
|
||||||
|
dismiss();
|
||||||
|
break;
|
||||||
|
case FAILURE_NETWORK:
|
||||||
|
Toast.makeText(requireContext(), R.string.GroupsV1MigrationInitiation_encountered_a_network_error, Toast.LENGTH_SHORT).show();
|
||||||
|
dismiss();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalStateException();
|
||||||
|
}
|
||||||
|
dialog.dismiss();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package org.thoughtcrime.securesms.groups.ui.migration;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.lifecycle.LiveData;
|
||||||
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
import androidx.lifecycle.ViewModel;
|
||||||
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupId;
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
|
|
||||||
|
class GroupsV1MigrationInitiationViewModel extends ViewModel {
|
||||||
|
|
||||||
|
private final RecipientId groupRecipientId;
|
||||||
|
private final MutableLiveData<MigrationState> migrationState;
|
||||||
|
private final GroupsV1MigrationRepository repository;
|
||||||
|
|
||||||
|
private GroupsV1MigrationInitiationViewModel(@NonNull RecipientId groupRecipientId) {
|
||||||
|
this.groupRecipientId = groupRecipientId;
|
||||||
|
this.migrationState = new MutableLiveData<>();
|
||||||
|
this.repository = new GroupsV1MigrationRepository();
|
||||||
|
|
||||||
|
repository.getMigrationState(groupRecipientId, migrationState::postValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull LiveData<MigrationState> getMigrationState() {
|
||||||
|
return migrationState;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull LiveData<MigrationResult> onUpgradeClicked() {
|
||||||
|
MutableLiveData <MigrationResult> migrationResult = new MutableLiveData<>();
|
||||||
|
|
||||||
|
repository.upgradeGroup(groupRecipientId, migrationResult::postValue);
|
||||||
|
|
||||||
|
return migrationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||||
|
|
||||||
|
private final RecipientId groupRecipientId;
|
||||||
|
|
||||||
|
Factory(@NonNull RecipientId groupRecipientId) {
|
||||||
|
this.groupRecipientId = groupRecipientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||||
|
return modelClass.cast(new GroupsV1MigrationInitiationViewModel(groupRecipientId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
package org.thoughtcrime.securesms.groups.ui.migration;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.WorkerThread;
|
||||||
|
import androidx.core.util.Consumer;
|
||||||
|
|
||||||
|
import com.annimon.stream.Collectors;
|
||||||
|
import com.annimon.stream.Stream;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil;
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
|
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||||
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||||
|
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||||
|
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
final class GroupsV1MigrationRepository {
|
||||||
|
|
||||||
|
private static final String TAG = Log.tag(GroupsV1MigrationRepository.class);
|
||||||
|
|
||||||
|
void getMigrationState(@NonNull RecipientId groupRecipientId, @NonNull Consumer<MigrationState> callback) {
|
||||||
|
SignalExecutors.BOUNDED.execute(() -> callback.accept(getMigrationState(groupRecipientId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void upgradeGroup(@NonNull RecipientId recipientId, @NonNull Consumer<MigrationResult> callback) {
|
||||||
|
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||||
|
if (!NetworkConstraint.isMet(ApplicationDependencies.getApplication())) {
|
||||||
|
Log.w(TAG, "No network!");
|
||||||
|
callback.accept(MigrationResult.FAILURE_NETWORK);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Recipient.resolved(recipientId).isPushV1Group()) {
|
||||||
|
Log.w(TAG, "Not a V1 group!");
|
||||||
|
callback.accept(MigrationResult.FAILURE_GENERAL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
GroupsV1MigrationUtil.migrate(ApplicationDependencies.getApplication(), recipientId, true);
|
||||||
|
callback.accept(MigrationResult.SUCCESS);
|
||||||
|
} catch (IOException | RetryLaterException | GroupChangeBusyException e) {
|
||||||
|
callback.accept(MigrationResult.FAILURE_NETWORK);
|
||||||
|
} catch (GroupsV1MigrationUtil.InvalidMigrationStateException e) {
|
||||||
|
callback.accept(MigrationResult.FAILURE_GENERAL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private MigrationState getMigrationState(@NonNull RecipientId groupRecipientId) {
|
||||||
|
Recipient group = Recipient.resolved(groupRecipientId);
|
||||||
|
|
||||||
|
if (!group.isPushV1Group()) {
|
||||||
|
return new MigrationState(Collections.emptyList(), Collections.emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<RecipientId> needsRefresh = Stream.of(group.getParticipants())
|
||||||
|
.filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED ||
|
||||||
|
r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED)
|
||||||
|
.map(Recipient::getId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
List<Job> jobs = RetrieveProfileJob.forRecipients(needsRefresh);
|
||||||
|
|
||||||
|
for (Job job : jobs) {
|
||||||
|
if (!ApplicationDependencies.getJobManager().runSynchronously(job, TimeUnit.SECONDS.toMillis(3)).isPresent()) {
|
||||||
|
Log.w(TAG, "Failed to refresh capabilities in time!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
RecipientUtil.ensureUuidsAreAvailable(ApplicationDependencies.getApplication(), group.getParticipants());
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "Failed to refresh UUIDs!", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
group = group.fresh();
|
||||||
|
|
||||||
|
List<Recipient> ineligible = Stream.of(group.getParticipants())
|
||||||
|
.filter(r -> !r.hasUuid() ||
|
||||||
|
r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED ||
|
||||||
|
r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED ||
|
||||||
|
r.getRegistered() != RecipientDatabase.RegisteredState.REGISTERED)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<Recipient> invites = Stream.of(group.getParticipants())
|
||||||
|
.filterNot(ineligible::contains)
|
||||||
|
.filterNot(Recipient::isSelf)
|
||||||
|
.filter(r -> r.getProfileKey() == null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new MigrationState(invites, ineligible);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package org.thoughtcrime.securesms.groups.ui.migration;
|
||||||
|
|
||||||
|
enum MigrationResult {
|
||||||
|
SUCCESS, FAILURE_GENERAL, FAILURE_NETWORK
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package org.thoughtcrime.securesms.groups.ui.migration;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the migration state of a group. Namely, which users will be invited or left behind.
|
||||||
|
*/
|
||||||
|
final class MigrationState {
|
||||||
|
private final List<Recipient> needsInvite;
|
||||||
|
private final List<Recipient> ineligible;
|
||||||
|
|
||||||
|
MigrationState(@NonNull List<Recipient> needsInvite,
|
||||||
|
@NonNull List<Recipient> ineligible)
|
||||||
|
{
|
||||||
|
this.needsInvite = needsInvite;
|
||||||
|
this.ineligible = ineligible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NonNull List<Recipient> getNeedsInvite() {
|
||||||
|
return needsInvite;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NonNull List<Recipient> getIneligible() {
|
||||||
|
return ineligible;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,44 +1,28 @@
|
||||||
package org.thoughtcrime.securesms.jobs;
|
package org.thoughtcrime.securesms.jobs;
|
||||||
|
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.content.Context;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
|
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
|
||||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.GroupAlreadyExistsException;
|
|
||||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil;
|
||||||
import org.thoughtcrime.securesms.groups.GroupDoesNotExistException;
|
|
||||||
import org.thoughtcrime.securesms.groups.GroupId;
|
|
||||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
|
||||||
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
|
|
||||||
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
|
|
||||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mms.MmsException;
|
|
||||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
|
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
|
||||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
|
||||||
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
|
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
|
||||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||||
|
|
||||||
|
@ -48,56 +32,40 @@ import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import static org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.LATEST;
|
|
||||||
|
|
||||||
public class GroupV1MigrationJob extends BaseJob {
|
public class GroupV1MigrationJob extends BaseJob {
|
||||||
|
|
||||||
private static final String TAG = Log.tag(GroupV1MigrationJob.class);
|
private static final String TAG = Log.tag(GroupV1MigrationJob.class);
|
||||||
|
|
||||||
public static final String KEY = "GroupV1MigrationJob";
|
public static final String KEY = "GroupV1MigrationJob";
|
||||||
|
|
||||||
private static final String KEY_RECIPIENT_ID = "recipient_id";
|
public static final long MANUAL_TIMEOUT = TimeUnit.SECONDS.toMillis(20);
|
||||||
private static final String KEY_FORCED = "forced";
|
|
||||||
|
|
||||||
private static final int ROUTINE_LIMIT = 50;
|
private static final String KEY_RECIPIENT_ID = "recipient_id";
|
||||||
|
|
||||||
|
private static final int ROUTINE_LIMIT = 50;
|
||||||
private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(3);
|
private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(3);
|
||||||
|
|
||||||
private static final Object MIGRATION_LOCK = new Object();
|
|
||||||
|
|
||||||
private final RecipientId recipientId;
|
private final RecipientId recipientId;
|
||||||
private final boolean forced;
|
|
||||||
|
|
||||||
private GroupV1MigrationJob(@NonNull RecipientId recipientId, boolean forced) {
|
private GroupV1MigrationJob(@NonNull RecipientId recipientId) {
|
||||||
this(updateParameters(new Parameters.Builder()
|
this(new Parameters.Builder()
|
||||||
.setQueue(recipientId.toQueueKey())
|
.setQueue(recipientId.toQueueKey())
|
||||||
.addConstraint(NetworkConstraint.KEY),
|
.setMaxAttempts(Parameters.UNLIMITED)
|
||||||
forced),
|
.setLifespan(TimeUnit.DAYS.toMillis(7))
|
||||||
recipientId,
|
.addConstraint(NetworkConstraint.KEY)
|
||||||
forced);
|
.build(),
|
||||||
|
recipientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Parameters updateParameters(@NonNull Parameters.Builder builder, boolean forced) {
|
private GroupV1MigrationJob(@NonNull Parameters parameters, @NonNull RecipientId recipientId) {
|
||||||
if (forced) {
|
|
||||||
return builder.setMaxAttempts(Parameters.UNLIMITED)
|
|
||||||
.setLifespan(TimeUnit.DAYS.toMillis(7))
|
|
||||||
.build();
|
|
||||||
} else {
|
|
||||||
return builder.setMaxAttempts(3)
|
|
||||||
.setLifespan(TimeUnit.MINUTES.toMillis(20))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private GroupV1MigrationJob(@NonNull Parameters parameters, @NonNull RecipientId recipientId, boolean forced) {
|
|
||||||
super(parameters);
|
super(parameters);
|
||||||
this.recipientId = recipientId;
|
this.recipientId = recipientId;
|
||||||
this.forced = forced;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void enqueuePossibleAutoMigrate(@NonNull RecipientId recipientId) {
|
public static void enqueuePossibleAutoMigrate(@NonNull RecipientId recipientId) {
|
||||||
SignalExecutors.BOUNDED.execute(() -> {
|
SignalExecutors.BOUNDED.execute(() -> {
|
||||||
if (Recipient.resolved(recipientId).isPushV1Group()) {
|
if (Recipient.resolved(recipientId).isPushV1Group()) {
|
||||||
ApplicationDependencies.getJobManager().add(new GroupV1MigrationJob(recipientId, false));
|
ApplicationDependencies.getJobManager().add(new GroupV1MigrationJob(recipientId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -132,7 +100,7 @@ public class GroupV1MigrationJob extends BaseJob {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ThreadRecord thread : threads) {
|
for (ThreadRecord thread : threads) {
|
||||||
jobManager.add(new GroupV1MigrationJob(thread.getRecipient().getId(), false));
|
jobManager.add(new GroupV1MigrationJob(thread.getRecipient().getId()));
|
||||||
|
|
||||||
needsRefresh.addAll(Stream.of(thread.getRecipient().getParticipants())
|
needsRefresh.addAll(Stream.of(thread.getRecipient().getParticipants())
|
||||||
.filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED ||
|
.filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED ||
|
||||||
|
@ -151,7 +119,6 @@ public class GroupV1MigrationJob extends BaseJob {
|
||||||
@Override
|
@Override
|
||||||
public @NonNull Data serialize() {
|
public @NonNull Data serialize() {
|
||||||
return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize())
|
return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize())
|
||||||
.putBoolean(KEY_FORCED, forced)
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,187 +128,14 @@ public class GroupV1MigrationJob extends BaseJob {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onRun() throws IOException, RetryLaterException {
|
protected void onRun() throws IOException, GroupChangeBusyException, RetryLaterException {
|
||||||
Recipient groupRecipient = Recipient.resolved(recipientId);
|
|
||||||
Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId);
|
|
||||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
|
||||||
|
|
||||||
if (threadId == null) {
|
|
||||||
warn(TAG, "No thread found!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groupRecipient.isPushV1Group()) {
|
|
||||||
warn(TAG, "Not a V1 group!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupRecipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) {
|
|
||||||
warn(TAG, "Too many members! Size: " + groupRecipient.getParticipants().size());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GroupId.V1 gv1Id = groupRecipient.requireGroupId().requireV1();
|
|
||||||
GroupId.V2 gv2Id = gv1Id.deriveV2MigrationGroupId();
|
|
||||||
GroupMasterKey gv2MasterKey = gv1Id.deriveV2MigrationMasterKey();
|
|
||||||
boolean newlyCreated = false;
|
|
||||||
|
|
||||||
if (groupDatabase.groupExists(gv2Id)) {
|
|
||||||
warn(TAG, "We already have a V2 group for this V1 group! Must have been added before we were migration-capable.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (GroupManager.v2GroupStatus(context, gv2MasterKey)) {
|
|
||||||
case DOES_NOT_EXIST:
|
|
||||||
log(TAG, "Group does not exist on the service.");
|
|
||||||
|
|
||||||
if (!groupRecipient.isActiveGroup()) {
|
|
||||||
warn(TAG, "Group is inactive! Can't migrate.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groupRecipient.isProfileSharing()) {
|
|
||||||
warn(TAG, "Profile sharing is disabled! Can't migrate.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!forced && SignalStore.internalValues().disableGv1AutoMigrateInitiation()) {
|
|
||||||
warn(TAG, "Auto migration initiation has been disabled! Skipping.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!forced && !FeatureFlags.groupsV1AutoMigration()) {
|
|
||||||
warn(TAG, "Auto migration is not enabled! Skipping.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forced && !FeatureFlags.groupsV1ManualMigration()) {
|
|
||||||
warn(TAG, "Manual migration is not enabled! Skipping.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
RecipientUtil.ensureUuidsAreAvailable(context, groupRecipient.getParticipants());
|
|
||||||
groupRecipient = groupRecipient.fresh();
|
|
||||||
|
|
||||||
List<Recipient> registeredMembers = RecipientUtil.getEligibleForSending(groupRecipient.getParticipants());
|
|
||||||
List<Recipient> possibleMembers = forced ? getMigratableManualMigrationMembers(registeredMembers)
|
|
||||||
: getMigratableAutoMigrationMembers(registeredMembers);
|
|
||||||
|
|
||||||
if (!forced && possibleMembers.size() != registeredMembers.size()) {
|
|
||||||
warn(TAG, "Not allowed to invite or leave registered users behind in an auto-migration! Skipping.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(TAG, "Attempting to create group.");
|
|
||||||
|
|
||||||
try {
|
|
||||||
GroupManager.migrateGroupToServer(context, gv1Id, possibleMembers);
|
|
||||||
newlyCreated = true;
|
|
||||||
log(TAG, "Successfully created!");
|
|
||||||
} catch (GroupChangeFailedException e) {
|
|
||||||
warn(TAG, "Failed to migrate group. Retrying.", e);
|
|
||||||
throw new RetryLaterException();
|
|
||||||
} catch (MembershipNotSuitableForV2Exception e) {
|
|
||||||
warn(TAG, "Failed to migrate job due to the membership not yet being suitable for GV2. Aborting.", e);
|
|
||||||
return;
|
|
||||||
} catch (GroupAlreadyExistsException e) {
|
|
||||||
warn(TAG, "Someone else created the group while we were trying to do the same! It exists now. Continuing on.", e);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case NOT_A_MEMBER:
|
|
||||||
warn(TAG, "The migrated group already exists, but we are not a member. Doing a local leave.");
|
|
||||||
handleLeftBehind(context, gv1Id, groupRecipient, threadId);
|
|
||||||
return;
|
|
||||||
case FULL_OR_PENDING_MEMBER:
|
|
||||||
warn(TAG, "The migrated group already exists, and we're in it. Continuing on.");
|
|
||||||
break;
|
|
||||||
default: throw new AssertionError();
|
|
||||||
}
|
|
||||||
|
|
||||||
log(TAG, "Migrating local group " + gv1Id + " to " + gv2Id);
|
|
||||||
|
|
||||||
DecryptedGroup decryptedGroup = performLocalMigration(context, gv1Id, threadId, groupRecipient);
|
|
||||||
|
|
||||||
if (newlyCreated && decryptedGroup != null && !SignalStore.internalValues().disableGv1AutoMigrateNotification()) {
|
|
||||||
GroupManager.sendNoopUpdate(context, gv2MasterKey, decryptedGroup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void performLocalMigration(@NonNull Context context, @NonNull GroupId.V1 gv1Id) throws IOException {
|
|
||||||
synchronized (MIGRATION_LOCK) {
|
|
||||||
Recipient recipient = Recipient.externalGroupExact(context, gv1Id);
|
|
||||||
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
|
|
||||||
|
|
||||||
performLocalMigration(context, gv1Id, threadId, recipient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static @Nullable DecryptedGroup performLocalMigration(@NonNull Context context, @NonNull GroupId.V1 gv1Id, long threadId, @NonNull Recipient groupRecipient) throws IOException {
|
|
||||||
synchronized (MIGRATION_LOCK) {
|
|
||||||
DecryptedGroup decryptedGroup;
|
|
||||||
try {
|
|
||||||
decryptedGroup = GroupManager.addedGroupVersion(context, gv1Id.deriveV2MigrationMasterKey());
|
|
||||||
} catch (GroupDoesNotExistException e) {
|
|
||||||
throw new IOException("[Local] The group should exist already!");
|
|
||||||
} catch (GroupNotAMemberException e) {
|
|
||||||
Log.w(TAG, "[Local] We are not in the group. Doing a local leave.");
|
|
||||||
handleLeftBehind(context, gv1Id, groupRecipient, threadId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<RecipientId> pendingRecipients = Stream.of(DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList()))
|
|
||||||
.map(uuid -> Recipient.externalPush(context, uuid, null, false))
|
|
||||||
.filterNot(Recipient::isSelf)
|
|
||||||
.map(Recipient::getId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
Log.i(TAG, "[Local] Migrating group over to the version we were added to: V" + decryptedGroup.getRevision());
|
|
||||||
DatabaseFactory.getGroupDatabase(context).migrateToV2(gv1Id, decryptedGroup);
|
|
||||||
DatabaseFactory.getSmsDatabase(context).insertGroupV1MigrationEvents(groupRecipient.getId(), threadId, pendingRecipients);
|
|
||||||
|
|
||||||
Log.i(TAG, "[Local] Applying all changes since V" + decryptedGroup.getRevision());
|
|
||||||
try {
|
|
||||||
GroupManager.updateGroupFromServer(context, gv1Id.deriveV2MigrationMasterKey(), LATEST, System.currentTimeMillis(), null);
|
|
||||||
} catch (GroupChangeBusyException | GroupNotAMemberException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return decryptedGroup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void handleLeftBehind(@NonNull Context context, @NonNull GroupId.V1 gv1Id, @NonNull Recipient groupRecipient, long threadId) {
|
|
||||||
DatabaseFactory.getGroupDatabase(context).setActive(gv1Id, false);
|
|
||||||
|
|
||||||
OutgoingMediaMessage leaveMessage = GroupUtil.createGroupV1LeaveMessage(gv1Id, groupRecipient);
|
|
||||||
try {
|
try {
|
||||||
long id = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(leaveMessage, threadId, false, null);
|
GroupsV1MigrationUtil.migrate(context, recipientId, false);
|
||||||
DatabaseFactory.getMmsDatabase(context).markAsSent(id, true);
|
} catch (GroupsV1MigrationUtil.InvalidMigrationStateException e) {
|
||||||
} catch (MmsException e) {
|
Log.w(TAG, "Invalid migration state. Skipping.");
|
||||||
Log.w(TAG, "Failed to insert group leave message!", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* In addition to meeting traditional requirements, you must also have a profile key for a member
|
|
||||||
* to consider them migratable in an auto-migration.
|
|
||||||
*/
|
|
||||||
private static @NonNull List<Recipient> getMigratableAutoMigrationMembers(@NonNull List<Recipient> registeredMembers) {
|
|
||||||
return Stream.of(getMigratableManualMigrationMembers(registeredMembers))
|
|
||||||
.filter(r -> r.getProfileKey() != null)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* You can only migrate users that have the required capabilities.
|
|
||||||
*/
|
|
||||||
private static @NonNull List<Recipient> getMigratableManualMigrationMembers(@NonNull List<Recipient> registeredMembers) {
|
|
||||||
return Stream.of(registeredMembers)
|
|
||||||
.filter(r -> r.getGroupsV2Capability() == Recipient.Capability.SUPPORTED &&
|
|
||||||
r.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||||
return e instanceof PushNetworkException ||
|
return e instanceof PushNetworkException ||
|
||||||
|
@ -357,9 +151,7 @@ public class GroupV1MigrationJob extends BaseJob {
|
||||||
public static final class Factory implements Job.Factory<GroupV1MigrationJob> {
|
public static final class Factory implements Job.Factory<GroupV1MigrationJob> {
|
||||||
@Override
|
@Override
|
||||||
public @NonNull GroupV1MigrationJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
public @NonNull GroupV1MigrationJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||||
return new GroupV1MigrationJob(parameters,
|
return new GroupV1MigrationJob(parameters, RecipientId.from(data.getString(KEY_RECIPIENT_ID)));
|
||||||
RecipientId.from(data.getString(KEY_RECIPIENT_ID)),
|
|
||||||
data.getBoolean(KEY_FORCED));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.groups.GroupId;
|
||||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||||
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
|
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
|
||||||
import org.thoughtcrime.securesms.groups.GroupV1MessageProcessor;
|
import org.thoughtcrime.securesms.groups.GroupV1MessageProcessor;
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||||
|
@ -357,7 +358,7 @@ public final class PushProcessMessageJob extends BaseJob {
|
||||||
|
|
||||||
Optional<GroupDatabase.GroupRecord> possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(groupIdV2);
|
Optional<GroupDatabase.GroupRecord> possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(groupIdV2);
|
||||||
if (possibleGv1.isPresent()) {
|
if (possibleGv1.isPresent()) {
|
||||||
GroupV1MigrationJob.performLocalMigration(context, possibleGv1.get().getId().requireV1());
|
GroupsV1MigrationUtil.performLocalMigration(context, possibleGv1.get().getId().requireV1());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updateGv2GroupFromServerOrP2PChange(content, message.getGroupContext().get().getGroupV2().get())) {
|
if (!updateGv2GroupFromServerOrP2PChange(content, message.getGroupContext().get().getGroupV2().get())) {
|
||||||
|
|
|
@ -6,13 +6,13 @@ import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
|
|
||||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
|
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
|
||||||
import org.thoughtcrime.securesms.database.StorageKeyDatabase;
|
import org.thoughtcrime.securesms.database.StorageKeyDatabase;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.GroupId;
|
import org.thoughtcrime.securesms.groups.GroupId;
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
|
@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels;
|
||||||
import org.thoughtcrime.securesms.storage.StorageSyncValidations;
|
import org.thoughtcrime.securesms.storage.StorageSyncValidations;
|
||||||
import org.thoughtcrime.securesms.tracing.Trace;
|
import org.thoughtcrime.securesms.tracing.Trace;
|
||||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
|
||||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
|
@ -56,7 +55,6 @@ import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -293,7 +291,7 @@ public class StorageSyncJob extends BaseJob {
|
||||||
|
|
||||||
if (idMap.containsKey(id)) {
|
if (idMap.containsKey(id)) {
|
||||||
Log.i(TAG, "Discovered a new GV2 ID that is actually a migrated V1 group! Migrating now.");
|
Log.i(TAG, "Discovered a new GV2 ID that is actually a migrated V1 group! Migrating now.");
|
||||||
GroupV1MigrationJob.performLocalMigration(context, idMap.get(id));
|
GroupsV1MigrationUtil.performLocalMigration(context, idMap.get(id));
|
||||||
recordIterator.remove();
|
recordIterator.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,6 +64,7 @@ public final class FeatureFlags {
|
||||||
private static final String MAX_ENVELOPE_SIZE = "android.maxEnvelopeSize";
|
private static final String MAX_ENVELOPE_SIZE = "android.maxEnvelopeSize";
|
||||||
private static final String GV1_AUTO_MIGRATE_VERSION = "android.groupsv2.autoMigrateVersion";
|
private static final String GV1_AUTO_MIGRATE_VERSION = "android.groupsv2.autoMigrateVersion";
|
||||||
private static final String GV1_MANUAL_MIGRATE_VERSION = "android.groupsv2.manualMigrateVersion";
|
private static final String GV1_MANUAL_MIGRATE_VERSION = "android.groupsv2.manualMigrateVersion";
|
||||||
|
private static final String GV1_FORCED_MIGRATE_VERSION = "android.groupsv2.forcedMigrateVersion";
|
||||||
private static final String GROUP_CALLING_VERSION = "android.groupsv2.callingVersion";
|
private static final String GROUP_CALLING_VERSION = "android.groupsv2.callingVersion";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -195,7 +196,7 @@ public final class FeatureFlags {
|
||||||
*/
|
*/
|
||||||
public static SelectionLimits groupLimits() {
|
public static SelectionLimits groupLimits() {
|
||||||
return new SelectionLimits(getInteger(GROUPS_V2_RECOMMENDED_LIMIT, 151),
|
return new SelectionLimits(getInteger(GROUPS_V2_RECOMMENDED_LIMIT, 151),
|
||||||
getInteger(GROUPS_V2_HARD_LIMIT, 1001));
|
getInteger(GROUPS_V2_HARD_LIMIT, 1001));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -276,10 +277,16 @@ public final class FeatureFlags {
|
||||||
return groupsV1AutoMigration() && getVersionFlag(GV1_MANUAL_MIGRATE_VERSION) == VersionFlag.ON;
|
return groupsV1AutoMigration() && getVersionFlag(GV1_MANUAL_MIGRATE_VERSION) == VersionFlag.ON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether or not group calling is enabled. */
|
||||||
public static boolean groupCalling() {
|
public static boolean groupCalling() {
|
||||||
return getVersionFlag(GROUP_CALLING_VERSION) == VersionFlag.ON;
|
return getVersionFlag(GROUP_CALLING_VERSION) == VersionFlag.ON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether or not forced migration from GV1->GV2 is enabled. */
|
||||||
|
public static boolean groupsV1ForcedMigration() {
|
||||||
|
return groupsV1AutoMigration() && getVersionFlag(GV1_FORCED_MIGRATE_VERSION) == VersionFlag.ON;
|
||||||
|
}
|
||||||
|
|
||||||
/** Only for rendering debug info. */
|
/** Only for rendering debug info. */
|
||||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||||
return new TreeMap<>(REMOTE_VALUES);
|
return new TreeMap<>(REMOTE_VALUES);
|
||||||
|
|
182
app/src/main/res/layout/groupsv1_migration_bottom_sheet.xml
Normal file
182
app/src/main/res/layout/groupsv1_migration_bottom_sheet.xml
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:paddingEnd="14dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
tools:theme="@style/Theme.Signal.RoundedBottomSheet.Light">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/gv1_migrate_title"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_marginEnd="20dp"
|
||||||
|
android:text="@string/GroupsV1MigrationInitiation_upgrade_to_new_group"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Title1"
|
||||||
|
android:textColor="@color/signal_text_primary"
|
||||||
|
android:gravity="center" />
|
||||||
|
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="23dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:src="@drawable/paragraph_marker" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:text="@string/GroupsV1MigrationInitiation_new_groups_have_features_like_mentions"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||||
|
android:textColor="@color/signal_text_primary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="23dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:src="@drawable/paragraph_marker" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:text="@string/GroupsV1MigrationInitiation_all_message_history_and_media_will_be_kept"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||||
|
android:textColor="@color/signal_text_primary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/gv1_migrate_invite_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="23dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:src="@drawable/paragraph_marker" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/gv1_migrate_invite_title"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||||
|
android:textColor="@color/signal_text_primary"
|
||||||
|
tools:text="Plurized string for invited members." />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
|
||||||
|
android:id="@+id/gv1_migrate_invite_list"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginStart="-12dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/gv1_migrate_ineligible_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="23dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:src="@drawable/paragraph_marker" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/gv1_migrate_ineligible_title"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||||
|
android:textColor="@color/signal_text_primary"
|
||||||
|
tools:text="Plurized string for ineligible members." />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
|
||||||
|
android:id="@+id/gv1_migrate_ineligible_list"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginStart="-12dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/gv1_migrate_upgrade_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
style="@style/Button.Primary"
|
||||||
|
android:text="@string/GroupsV1MigrationInitiation_upgrade_this_group" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/gv1_migrate_cancel_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="7dp"
|
||||||
|
style="@style/Button.Borderless"
|
||||||
|
android:text="@android:string/cancel" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
|
@ -22,89 +22,112 @@
|
||||||
android:textColor="@color/signal_text_primary"
|
android:textColor="@color/signal_text_primary"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
<LinearLayout
|
<androidx.core.widget.NestedScrollView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content">
|
||||||
android:layout_marginTop="23dp"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:src="@drawable/paragraph_marker" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="14dp"
|
|
||||||
android:text="@string/GroupsV1MigrationLearnMore_new_groups_have_features_like_mentions"
|
|
||||||
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
|
||||||
android:textColor="@color/signal_text_primary" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="23dp"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:src="@drawable/paragraph_marker" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="14dp"
|
|
||||||
android:text="@string/GroupsV1MigrationLearnMore_all_message_history_and_media_has_been_kept"
|
|
||||||
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
|
||||||
android:textColor="@color/signal_text_primary" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/gv1_learn_more_pending_container"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="23dp"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:src="@drawable/paragraph_marker" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="20dp"
|
|
||||||
android:layout_marginEnd="14dp"
|
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/gv1_learn_more_pending_title"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
|
||||||
android:textColor="@color/signal_text_primary"
|
|
||||||
tools:text="Plurized string for pending members." />
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
|
|
||||||
android:id="@+id/gv1_learn_more_pending_list"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="23dp"
|
||||||
android:layout_marginStart="-12dp"/>
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:src="@drawable/paragraph_marker" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:text="@string/GroupsV1MigrationLearnMore_new_groups_have_features_like_mentions"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||||
|
android:textColor="@color/signal_text_primary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="23dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:src="@drawable/paragraph_marker" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:text="@string/GroupsV1MigrationLearnMore_all_message_history_and_media_has_been_kept"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||||
|
android:textColor="@color/signal_text_primary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/gv1_learn_more_pending_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="23dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:src="@drawable/paragraph_marker" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="20dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/gv1_learn_more_pending_title"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||||
|
android:textColor="@color/signal_text_primary"
|
||||||
|
tools:text="Plurized string for pending members." />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
|
||||||
|
android:id="@+id/gv1_learn_more_pending_list"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginStart="-12dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/gv1_learn_more_ok_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
style="@style/Button.Primary"
|
||||||
|
android:text="@android:string/ok" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
|
@ -11,4 +11,7 @@
|
||||||
|
|
||||||
<item name="reminder_action_gv1_suggestion_not_now" type="id" />
|
<item name="reminder_action_gv1_suggestion_not_now" type="id" />
|
||||||
<item name="reminder_action_gv1_suggestion_add_members" type="id" />
|
<item name="reminder_action_gv1_suggestion_add_members" type="id" />
|
||||||
|
|
||||||
|
<item name="reminder_action_gv1_initiation_not_now" type="id" />
|
||||||
|
<item name="reminder_action_gv1_initiation_update_group" type="id" />
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -561,10 +561,27 @@
|
||||||
<item quantity="one">This member will need to accept an invite to join this group again and will not receive group messages until they accept:</item>
|
<item quantity="one">This member will need to accept an invite to join this group again and will not receive group messages until they accept:</item>
|
||||||
<item quantity="other">These members will need to accept an invite to join this group again and will not receive group messages until they accept:</item>
|
<item quantity="other">These members will need to accept an invite to join this group again and will not receive group messages until they accept:</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="GroupsV1MigrationLearnMore_these_members_are_not_capable_of_joining_new_groups">
|
|
||||||
<item quantity="one">This member is not capable of joining New Groups, and has been removed from the group:</item>
|
<!-- GroupsV1MigrationInitiationBottomSheetDialogFragment -->
|
||||||
<item quantity="other">These members are not capable of joining New Groups, and have been removed from the group:</item>
|
<string name="GroupsV1MigrationInitiation_upgrade_to_new_group">Upgrade to New Group</string>
|
||||||
|
<string name="GroupsV1MigrationInitiation_upgrade_this_group">Upgrade this group</string>
|
||||||
|
<string name="GroupsV1MigrationInitiation_new_groups_have_features_like_mentions">New Groups have features like @mentions and group admins, and will support more features in the future.</string>
|
||||||
|
<string name="GroupsV1MigrationInitiation_all_message_history_and_media_will_be_kept">All message history and media will be kept from before the upgrade.</string>
|
||||||
|
<string name="GroupsV1MigrationInitiation_encountered_a_network_error">Encountered a network error. Try again later.</string>
|
||||||
|
<string name="GroupsV1MigrationInitiation_failed_to_upgrade">Failed to upgrade.</string>
|
||||||
|
<plurals name="GroupsV1MigrationInitiation_these_members_will_need_to_accept_an_invite">
|
||||||
|
<item quantity="one">This member will need to accept an invite to join this group again and will not receive group messages until they accept:</item>
|
||||||
|
<item quantity="other">These members will need to accept an invite to join this group again and will not receive group messages until they accept:</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="GroupsV1MigrationInitiation_these_members_are_not_capable_of_joining_new_groups">
|
||||||
|
<item quantity="one">This member is not capable of joining New Groups, and will be removed from the group:</item>
|
||||||
|
<item quantity="other">These members are not capable of joining New Groups, and will be removed from the group:</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- GroupsV1MigrationInitiationReminder -->
|
||||||
|
<string name="GroupsV1MigrationInitiationReminder_to_access_new_features_like_mentions">To access new features like @mentions and admins, upgrade this group.</string>
|
||||||
|
<string name="GroupsV1MigrationInitiationReminder_not_now">Not now</string>
|
||||||
|
<string name="GroupsV1MigrationInitiationReminder_update_group">Update group</string>
|
||||||
|
|
||||||
<!-- GroupsV1MigrationSuggestionsReminder -->
|
<!-- GroupsV1MigrationSuggestionsReminder -->
|
||||||
<plurals name="GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group">
|
<plurals name="GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group">
|
||||||
|
@ -731,6 +748,9 @@
|
||||||
<string name="ManageGroupActivity_edit_name_and_picture">Edit name and picture</string>
|
<string name="ManageGroupActivity_edit_name_and_picture">Edit name and picture</string>
|
||||||
<string name="ManageGroupActivity_legacy_group">Legacy Group</string>
|
<string name="ManageGroupActivity_legacy_group">Legacy Group</string>
|
||||||
<string name="ManageGroupActivity_legacy_group_learn_more">This is a Legacy Group. Features like group admins are only available for New Groups.</string>
|
<string name="ManageGroupActivity_legacy_group_learn_more">This is a Legacy Group. Features like group admins are only available for New Groups.</string>
|
||||||
|
<string name="ManageGroupActivity_legacy_group_upgrade">This is a Legacy Group. To access new features like @mentions and admins,</string>
|
||||||
|
<string name="ManageGroupActivity_legacy_group_too_large">This Legacy Group can’t be upgraded to a New Group because it is too large. The maximum group size is %1$d.</string>
|
||||||
|
<string name="ManageGroupActivity_upgrade_this_group">upgrade this group.</string>
|
||||||
<string name="ManageGroupActivity_this_is_an_insecure_mms_group">This is an insecure MMS Group. To chat privately and access features like group names, invite your contacts to Signal.</string>
|
<string name="ManageGroupActivity_this_is_an_insecure_mms_group">This is an insecure MMS Group. To chat privately and access features like group names, invite your contacts to Signal.</string>
|
||||||
<string name="ManageGroupActivity_invite_now">Invite now</string>
|
<string name="ManageGroupActivity_invite_now">Invite now</string>
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue