Add ability to hide contacts behind a feature flag.

This commit is contained in:
Alex Hart 2022-09-27 16:40:27 -03:00 committed by Cody Henthorne
parent a8a773db43
commit 04eeb434c9
19 changed files with 511 additions and 45 deletions

View file

@ -259,7 +259,7 @@ class MmsDatabaseTest_stories {
)
// WHEN
val result = mms.hasSelfReplyInStory(groupStoryId)
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertFalse(result)
@ -284,7 +284,7 @@ class MmsDatabaseTest_stories {
)
// WHEN
val result = mms.hasSelfReplyInStory(groupStoryId)
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertTrue(result)
@ -309,7 +309,7 @@ class MmsDatabaseTest_stories {
)
// WHEN
val result = mms.hasSelfReplyInStory(groupStoryId)
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertFalse(result)
@ -337,7 +337,7 @@ class MmsDatabaseTest_stories {
)
// WHEN
val result = mms.hasSelfReplyInStory(groupStoryId)
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertFalse(result)

View file

@ -18,6 +18,77 @@ class RecipientDatabaseTest {
@get:Rule
val harness = SignalActivityRule()
@Test
fun givenAHiddenRecipient_whenIQueryAllContacts_thenIDoNotExpectHiddenToBeReturned() {
val hiddenRecipient = harness.others[0]
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
SignalDatabase.recipients.markHidden(hiddenRecipient)
val results = SignalDatabase.recipients.queryAllContacts("Hidden")!!
assertEquals(0, results.count)
}
@Test
fun givenAHiddenRecipient_whenIGetSignalContacts_thenIDoNotExpectHiddenToBeReturned() {
val hiddenRecipient = harness.others[0]
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
SignalDatabase.recipients.markHidden(hiddenRecipient)
val results: MutableList<RecipientId> = SignalDatabase.recipients.getSignalContacts(false)?.use {
val ids = mutableListOf<RecipientId>()
while (it.moveToNext()) {
ids.add(RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID)))
}
ids
}!!
assertNotEquals(0, results.size)
assertFalse(hiddenRecipient in results)
}
@Test
fun givenAHiddenRecipient_whenIQuerySignalContacts_thenIDoNotExpectHiddenToBeReturned() {
val hiddenRecipient = harness.others[0]
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
SignalDatabase.recipients.markHidden(hiddenRecipient)
val results = SignalDatabase.recipients.querySignalContacts("Hidden", false)!!
assertEquals(0, results.count)
}
@Test
fun givenAHiddenRecipient_whenIQueryNonGroupContacts_thenIDoNotExpectHiddenToBeReturned() {
val hiddenRecipient = harness.others[0]
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
SignalDatabase.recipients.markHidden(hiddenRecipient)
val results = SignalDatabase.recipients.queryNonGroupContacts("Hidden", false)!!
assertEquals(0, results.count)
}
@Test
fun givenAHiddenRecipient_whenIGetNonGroupContacts_thenIDoNotExpectHiddenToBeReturned() {
val hiddenRecipient = harness.others[0]
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
SignalDatabase.recipients.markHidden(hiddenRecipient)
val results: MutableList<RecipientId> = SignalDatabase.recipients.getNonGroupContacts(false)?.use {
val ids = mutableListOf<RecipientId>()
while (it.moveToNext()) {
ids.add(RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID)))
}
ids
}!!
assertNotEquals(0, results.size)
assertFalse(hiddenRecipient in results)
}
@Test
fun givenABlockedRecipient_whenIQueryAllContacts_thenIDoNotExpectBlockedToBeReturned() {
val blockedRecipient = harness.others[0]

View file

@ -144,20 +144,20 @@ public final class ContactSelectionListFragment extends LoggingFragment
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf;
@Override
public void onAttach(@NonNull Context context) {
@ -206,6 +206,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (getParentFragment() instanceof HeaderActionProvider) {
headerActionProvider = (HeaderActionProvider) getParentFragment();
}
if (context instanceof OnItemLongClickListener) {
onItemLongClickListener = (OnItemLongClickListener) context;
}
if (getParentFragment() instanceof OnItemLongClickListener) {
onItemLongClickListener = (OnItemLongClickListener) getParentFragment();
}
}
@Override
@ -720,6 +728,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
}
@Override
public boolean onItemLongClick(ContactSelectionListItem item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(item);
} else {
return false;
}
}
}
private boolean selectionHardLimitReached() {
@ -850,6 +867,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
@NonNull HeaderAction getHeaderAction();
}
public interface OnItemLongClickListener {
boolean onLongClick(ContactSelectionListItem contactSelectionListItem);
}
public interface AbstractContactsCursorLoaderFactoryProvider {
@NonNull AbstractContactsCursorLoader.Factory get();
}

View file

@ -20,11 +20,27 @@ import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase;
@ -33,32 +49,57 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.core.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Activity container for starting a new conversation.
*
* @author Moxie Marlinspike
*
*/
public class NewConversationActivity extends ContactSelectionActivity
implements ContactSelectionListFragment.ListCallback
implements ContactSelectionListFragment.ListCallback, ContactSelectionListFragment.OnItemLongClickListener
{
@SuppressWarnings("unused")
private static final String TAG = Log.tag(NewConversationActivity.class);
private ContactsManagementViewModel viewModel;
private ActivityResultLauncher<Intent> contactLauncher;
private final LifecycleDisposable disposables = new LifecycleDisposable();
@Override
public void onCreate(Bundle bundle, boolean ready) {
super.onCreate(bundle, ready);
assert getSupportActionBar() != null;
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
disposables.bindTo(this);
ContactsManagementRepository repository = new ContactsManagementRepository(this);
ContactsManagementViewModel.Factory factory = new ContactsManagementViewModel.Factory(repository);
contactLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), activityResult -> {
if (activityResult.getResultCode() == RESULT_OK) {
handleManualRefresh();
}
});
viewModel = new ViewModelProvider(this, factory).get(ContactsManagementViewModel.class);
}
@Override
@ -120,10 +161,18 @@ public class NewConversationActivity extends ContactSelectionActivity
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case android.R.id.home: super.onBackPressed(); return true;
case R.id.menu_refresh: handleManualRefresh(); return true;
case R.id.menu_new_group: handleCreateGroup(); return true;
case R.id.menu_invite: handleInvite(); return true;
case android.R.id.home:
super.onBackPressed();
return true;
case R.id.menu_refresh:
handleManualRefresh();
return true;
case R.id.menu_new_group:
handleCreateGroup();
return true;
case R.id.menu_invite:
handleInvite();
return true;
}
return false;
@ -162,4 +211,143 @@ public class NewConversationActivity extends ContactSelectionActivity
handleCreateGroup();
finish();
}
@Override
public boolean onLongClick(ContactSelectionListItem contactSelectionListItem) {
RecipientId recipientId = contactSelectionListItem.getRecipientId().orElse(null);
if (recipientId == null) {
return false;
}
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
if (actions.isEmpty()) {
return false;
}
new SignalContextMenu.Builder(contactSelectionListItem, (ViewGroup) contactSelectionListItem.getRootView())
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX((int) DimensionUnit.DP.toPixels(12))
.offsetY((int) DimensionUnit.DP.toPixels(12))
.show(actions);
return true;
}
private @NonNull List<ActionItem> generateContextualActionsForRecipient(@NonNull RecipientId recipientId) {
Recipient recipient = Recipient.resolved(recipientId);
return Stream.of(
createMessageActionItem(recipient),
createAudioCallActionItem(recipient),
createVideoCallActionItem(recipient),
createRemoveActionItem(recipient),
createBlockActionItem(recipient)
).filter(Objects::nonNull).collect(Collectors.toList());
}
private @NonNull ActionItem createMessageActionItem(@NonNull Recipient recipient) {
return new ActionItem(
R.drawable.ic_message_24,
getString(R.string.NewConversationActivity__message),
R.color.signal_colorOnSurface,
() -> startActivity(ConversationIntents.createBuilder(this, recipient.getId(), -1L).build())
);
}
private @Nullable ActionItem createAudioCallActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isGroup()) {
return null;
}
return new ActionItem(
R.drawable.ic_phone_right_24,
getString(R.string.NewConversationActivity__audio_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVoiceCall(this, recipient)
);
}
private @Nullable ActionItem createVideoCallActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isMmsGroup()) {
return null;
}
return new ActionItem(
R.drawable.ic_video_call_24,
getString(R.string.NewConversationActivity__video_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVideoCall(this, recipient)
);
}
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
if (!FeatureFlags.hideContacts() || recipient.isSelf() || recipient.isGroup()) {
return null;
}
return new ActionItem(
R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
getString(R.string.NewConversationActivity__remove),
R.color.signal_colorOnSurface,
() -> {
if (recipient.isSystemContact()) {
displayIsInSystemContactsDialog(recipient);
} else {
displayRemovalDialog(recipient);
}
}
);
}
@SuppressWarnings("CodeBlock2Expr")
private @Nullable ActionItem createBlockActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf()) {
return null;
}
return new ActionItem(
R.drawable.ic_block_tinted_24,
getString(R.string.NewConversationActivity__block),
R.color.signal_colorError,
() -> BlockUnblockDialog.showBlockFor(this,
this.getLifecycle(),
recipient,
() -> {
disposables.add(viewModel.blockContact(recipient).subscribe(() -> {
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed);
}));
})
);
}
private void displayIsInSystemContactsDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.NewConversationActivity__unable_to_remove_s, recipient.getShortDisplayName(this)))
.setMessage(R.string.NewConversationActivity__this_person_is_saved_to_your)
.setPositiveButton(R.string.NewConversationActivity__view_contact,
(dialog, which) -> contactLauncher.launch(new Intent(Intent.ACTION_VIEW, recipient.getContactUri()))
)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void displayRemovalDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.NewConversationActivity__remove_s, recipient.getShortDisplayName(this)))
.setMessage(R.string.NewConversationActivity__you_wont_see_this_person)
.setPositiveButton(R.string.NewConversationActivity__remove,
(dialog, which) -> {
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed);
}));
}
)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void displaySnackbar(@StringRes int message) {
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_SHORT).show();
}
}

View file

@ -130,6 +130,14 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
itemView.setOnClickListener(v -> {
if (clickListener != null) clickListener.onItemClick(getView());
});
itemView.setOnLongClickListener(v -> {
if (clickListener != null) {
return clickListener.onItemLongClick(getView());
} else {
return false;
}
});
}
public ContactSelectionListItem getView() {
@ -435,5 +443,6 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
public interface ItemClickListener {
void onItemClick(ContactSelectionListItem item);
boolean onItemLongClick(ContactSelectionListItem item);
}
}

View file

@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.contacts.management
import android.content.Context
import androidx.annotation.CheckResult
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientUtil
class ContactsManagementRepository(context: Context) {
private val context = context.applicationContext
@CheckResult
fun blockContact(recipient: Recipient): Completable {
return Completable.fromAction {
if (recipient.isDistributionList) {
error("Blocking a distribution list makes no sense")
} else if (recipient.isGroup) {
RecipientUtil.block(context, recipient)
} else {
RecipientUtil.blockNonGroup(context, recipient)
}
}.subscribeOn(Schedulers.io())
}
@CheckResult
fun hideContact(recipient: Recipient): Completable {
return Completable.fromAction {
if (recipient.isGroup || recipient.isDistributionList || recipient.isSelf) {
error("Cannot hide groups, self, or distribution lists.")
}
SignalDatabase.recipients.markHidden(recipient.id)
}
}
}

View file

@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.contacts.management
import androidx.annotation.CheckResult
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import org.thoughtcrime.securesms.recipients.Recipient
class ContactsManagementViewModel(private val repository: ContactsManagementRepository) : ViewModel() {
@CheckResult
fun hideContact(recipient: Recipient): Completable {
return repository.hideContact(recipient).observeOn(AndroidSchedulers.mainThread())
}
@CheckResult
fun blockContact(recipient: Recipient): Completable {
return repository.blockContact(recipient).observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val repository: ContactsManagementRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ContactsManagementViewModel(repository)) as T
}
}
}

View file

@ -184,6 +184,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
private const val IDENTITY_KEY = "identity_key"
private const val NEEDS_PNI_SIGNATURE = "needs_pni_signature"
private const val UNREGISTERED_TIMESTAMP = "unregistered_timestamp"
private const val HIDDEN = "hidden"
@JvmField
val CREATE_TABLE =
@ -243,7 +244,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
$PNI_COLUMN TEXT DEFAULT NULL,
$DISTRIBUTION_LIST_ID INTEGER DEFAULT NULL,
$NEEDS_PNI_SIGNATURE INTEGER DEFAULT 0,
$UNREGISTERED_TIMESTAMP INTEGER DEFAULT 0
$UNREGISTERED_TIMESTAMP INTEGER DEFAULT 0,
$HIDDEN INTEGER DEFAULT 0
)
""".trimIndent()
@ -304,7 +306,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
CUSTOM_CHAT_COLORS_ID,
BADGES,
DISTRIBUTION_LIST_ID,
NEEDS_PNI_SIGNATURE
NEEDS_PNI_SIGNATURE,
HIDDEN
)
private val ID_PROJECTION = arrayOf(ID)
@ -386,7 +389,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
$TABLE_NAME.$REGISTERED = ${RegisteredState.NOT_REGISTERED.id} AND
$TABLE_NAME.$SEEN_INVITE_REMINDER < ${InsightsBannerTier.TIER_TWO.id} AND
${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.HAS_SENT} AND
${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.DATE} > ?
${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.DATE} > ? AND
$TABLE_NAME.$HIDDEN = 0
ORDER BY ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.DATE} DESC LIMIT 50
"""
}
@ -1820,8 +1824,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
fun getSimilarRecipientIds(recipient: Recipient): List<RecipientId> {
val projection = SqlUtil.buildArgs(ID, "COALESCE(NULLIF($SYSTEM_JOINED_NAME, ''), NULLIF($PROFILE_JOINED_NAME, '')) AS checked_name")
val where = "checked_name = ?"
val arguments = SqlUtil.buildArgs(recipient.profileName.toString())
val where = "checked_name = ? AND $HIDDEN = ?"
val arguments = SqlUtil.buildArgs(recipient.profileName.toString(), 0)
readableDatabase.query(TABLE_NAME, projection, where, arguments, null, null, null).use { cursor ->
if (cursor == null || cursor.count == 0) {
@ -1881,10 +1885,31 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
}
fun markHidden(id: RecipientId) {
val contentValues = contentValuesOf(
HIDDEN to 1,
PROFILE_SHARING to 0
)
val updated = writableDatabase.update(TABLE_NAME, contentValues, "$ID_WHERE AND $GROUP_TYPE = ?", SqlUtil.buildArgs(id, GroupType.NONE.id)) > 0
if (updated) {
rotateStorageId(id)
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(id)
StorageSyncHelper.scheduleSyncForDataChange()
} else {
Log.w(TAG, "Failed to hide recipient $id")
}
}
fun setProfileSharing(id: RecipientId, enabled: Boolean) {
val contentValues = ContentValues(1).apply {
put(PROFILE_SHARING, if (enabled) 1 else 0)
}
if (enabled) {
contentValues.put(HIDDEN, 0)
}
val profiledUpdated = update(id, contentValues)
if (profiledUpdated && enabled) {
@ -2961,7 +2986,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
fun getRegistered(): List<RecipientId> {
val results: MutableList<RecipientId> = LinkedList()
readableDatabase.query(TABLE_NAME, ID_PROJECTION, "$REGISTERED = ?", arrayOf("1"), null, null, null).use { cursor ->
readableDatabase.query(TABLE_NAME, ID_PROJECTION, "$REGISTERED = ? and $HIDDEN = ?", arrayOf("1", "0"), null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))))
}
@ -3127,7 +3152,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
val selection =
"""
$BLOCKED = ? AND
$BLOCKED = ? AND $HIDDEN = ? AND
(
$SORT_NAME GLOB ? OR
$USERNAME GLOB ? OR
@ -3135,7 +3160,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
$EMAIL GLOB ?
)
""".trimIndent()
val args = SqlUtil.buildArgs("0", query, query, query, query)
val args = SqlUtil.buildArgs(0, 0, query, query, query, query)
return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null)
}
@ -3323,9 +3348,11 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
if (Util.hasItems(idsToUpdate)) {
val query = SqlUtil.buildSingleCollectionQuery(ID, idsToUpdate)
val values = ContentValues(1).apply {
put(PROFILE_SHARING, 1)
}
val values = contentValuesOf(
PROFILE_SHARING to 1,
HIDDEN to 0
)
writableDatabase.update(TABLE_NAME, values, query.where, query.whereArgs)
@ -3588,6 +3615,10 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
MENTION_SETTING to if (primaryRecord.mentionSetting != MentionSetting.ALWAYS_NOTIFY) primaryRecord.mentionSetting.id else secondaryRecord.mentionSetting.id
)
if (primaryRecord.profileSharing || secondaryRecord.profileSharing) {
uuidValues.put(HIDDEN, 0)
}
if (primaryRecord.profileKey != null) {
updateProfileValuesForMerge(uuidValues, primaryRecord)
} else if (secondaryRecord.profileKey != null) {
@ -3657,6 +3688,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
put(BLOCKED, if (contact.isBlocked) "1" else "0")
put(MUTE_UNTIL, contact.muteUntil)
put(STORAGE_SERVICE_ID, Base64.encodeBytes(contact.id.raw))
put(HIDDEN, contact.isHidden)
if (contact.hasUnknownFields()) {
put(STORAGE_PROTO, Base64.encodeBytes(Objects.requireNonNull(contact.serializeUnknownFields())))
@ -3908,7 +3940,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
extras = getExtras(cursor),
hasGroupsInCommon = cursor.requireBoolean(GROUPS_IN_COMMON),
badges = parseBadgeList(cursor.requireBlob(BADGES)),
needsPniSignature = cursor.requireBoolean(NEEDS_PNI_SIGNATURE)
needsPniSignature = cursor.requireBoolean(NEEDS_PNI_SIGNATURE),
isHidden = cursor.requireBoolean(HIDDEN)
)
}
@ -4187,6 +4220,9 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
stringBuilder.append(FILTER_BLOCKED)
args.add(0)
stringBuilder.append(FILTER_HIDDEN)
args.add(0)
if (excludeGroups) {
stringBuilder.append(FILTER_GROUPS)
}
@ -4204,6 +4240,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
const val FILTER_GROUPS = " AND $GROUP_ID IS NULL"
const val FILTER_ID = " AND $ID != ?"
const val FILTER_BLOCKED = " AND $BLOCKED = ?"
const val FILTER_HIDDEN = " AND $HIDDEN = ?"
const val NON_SIGNAL_CONTACT = "$REGISTERED != ? AND $SYSTEM_CONTACT_URI NOT NULL AND ($PHONE NOT NULL OR $EMAIL NOT NULL)"
const val QUERY_NON_SIGNAL_CONTACT = "$NON_SIGNAL_CONTACT AND ($PHONE GLOB ? OR $EMAIL GLOB ? OR $SYSTEM_JOINED_NAME GLOB ?)"
const val SIGNAL_CONTACT = "$REGISTERED = ? AND (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)"
@ -4217,7 +4254,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
*/
internal object Capabilities {
const val BIT_LENGTH = 2
// const val GROUPS_V2 = 0
// const val GROUPS_V2 = 0
const val GROUPS_V1_MIGRATION = 1
const val SENDER_KEY = 2
const val ANNOUNCEMENT_GROUPS = 3

View file

@ -11,13 +11,14 @@ import org.thoughtcrime.securesms.database.helpers.migration.V153_MyStoryMigrati
import org.thoughtcrime.securesms.database.helpers.migration.V154_PniSignaturesMigration
import org.thoughtcrime.securesms.database.helpers.migration.V155_SmsExporterMigration
import org.thoughtcrime.securesms.database.helpers.migration.V156_RecipientUnregisteredTimestampMigration
import org.thoughtcrime.securesms.database.helpers.migration.V157_RecipeintHiddenMigration
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
*/
object SignalDatabaseMigrations {
const val DATABASE_VERSION = 156
const val DATABASE_VERSION = 157
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@ -52,6 +53,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 156) {
V156_RecipientUnregisteredTimestampMigration.migrate(context, db, oldVersion, newVersion)
}
if (oldVersion < 157) {
V157_RecipeintHiddenMigration.migrate(context, db, oldVersion, newVersion)
}
}
@JvmStatic

View file

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
object V157_RecipeintHiddenMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE recipient ADD COLUMN hidden INTEGER DEFAULT 0")
}
}

View file

@ -85,7 +85,8 @@ data class RecipientRecord(
val hasGroupsInCommon: Boolean,
val badges: List<Badge>,
@get:JvmName("needsPniSignature")
val needsPniSignature: Boolean
val needsPniSignature: Boolean,
val isHidden: Boolean
) {
fun getDefaultSubscriptionId(): Optional<Int> {

View file

@ -197,8 +197,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
long muteUntil = remote.getMuteUntil();
boolean hideStory = remote.shouldHideStory();
long unregisteredTimestamp = remote.getUnregisteredTimestamp();
boolean matchesRemote = doParamsMatch(remote, unknownFields, serviceId, pni, e164, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread, muteUntil, hideStory, unregisteredTimestamp);
boolean matchesLocal = doParamsMatch(local, unknownFields, serviceId, pni, e164, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread, muteUntil, hideStory, unregisteredTimestamp);
boolean hidden = remote.isHidden();
boolean matchesRemote = doParamsMatch(remote, unknownFields, serviceId, pni, e164, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread, muteUntil, hideStory, unregisteredTimestamp, hidden);
boolean matchesLocal = doParamsMatch(local, unknownFields, serviceId, pni, e164, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread, muteUntil, hideStory, unregisteredTimestamp, hidden);
if (matchesRemote) {
return remote;
@ -221,6 +222,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
.setMuteUntil(muteUntil)
.setHideStory(hideStory)
.setUnregisteredTimestamp(unregisteredTimestamp)
.setHidden(hidden)
.build();
}
}
@ -264,7 +266,8 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
boolean forcedUnread,
long muteUntil,
boolean hideStory,
long unregisteredTimestamp)
long unregisteredTimestamp,
boolean hidden)
{
return Arrays.equals(contact.serializeUnknownFields(), unknownFields) &&
Objects.equals(contact.getServiceId(), serviceId) &&
@ -282,6 +285,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
contact.isForcedUnread() == forcedUnread &&
contact.getMuteUntil() == muteUntil &&
contact.shouldHideStory() == hideStory &&
contact.getUnregisteredTimestamp() == unregisteredTimestamp;
contact.getUnregisteredTimestamp() == unregisteredTimestamp &&
contact.isHidden() == hidden;
}
}

View file

@ -127,6 +127,7 @@ public final class StorageSyncModels {
.setMuteUntil(recipient.getMuteUntil())
.setHideStory(hideStory)
.setUnregisteredTimestamp(recipient.getSyncExtras().getUnregisteredTimestamp())
.setHidden(recipient.isHidden())
.build();
}

View file

@ -106,6 +106,7 @@ public final class FeatureFlags {
private static final String SMS_EXPORTER = "android.sms.exporter";
private static final String CDS_V2_COMPAT = "android.cdsV2Compat.3";
public static final String STORIES_LOCALE = "android.stories.locale";
private static final String HIDE_CONTACTS = "android.hide.contacts";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@ -163,7 +164,8 @@ public final class FeatureFlags {
CDS_V2_LOAD_TEST,
SMS_EXPORTER,
CDS_V2_COMPAT,
STORIES_LOCALE
STORIES_LOCALE,
HIDE_CONTACTS
);
@VisibleForTesting
@ -588,6 +590,16 @@ public final class FeatureFlags {
return getBoolean(CDS_V2_COMPAT, false);
}
/**
* Whether or not users can hide contacts.
*
* WARNING: This feature is intended to be enabled in tandem with other clients, as it modifies contact records.
* Here be dragons.
*/
public static boolean hideContacts() {
return getBoolean(HIDE_CONTACTS, false);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View file

@ -4185,6 +4185,30 @@
<!-- NewConversationActivity -->
<string name="NewConversationActivity__new_message">New message</string>
<!-- Context menu item message -->
<string name="NewConversationActivity__message">Message</string>
<!-- Context menu item audio call -->
<string name="NewConversationActivity__audio_call">Audio call</string>
<!-- Context menu item video call -->
<string name="NewConversationActivity__video_call">Video call</string>
<!-- Context menu item remove -->
<string name="NewConversationActivity__remove">Remove</string>
<!-- Context menu item block -->
<string name="NewConversationActivity__block">Block</string>
<!-- Dialog title when removing a contact -->
<string name="NewConversationActivity__remove_s">Remove %1$s?</string>
<!-- Dialog message when removing a contact -->
<string name="NewConversationActivity__you_wont_see_this_person">You won\'t see this person when searching. You\'ll get a message request if they message you in the future.</string>
<!-- Snackbar message after removing a contact -->
<string name="NewConversationActivity__s_has_been_removed">%1$s has been removed</string>
<!-- Snackbar message after blocking a contact -->
<string name="NewConversationActivity__s_has_been_blocked">%1$s has been blocked</string>
<!-- Dialog title when remove target contact is in system contacts -->
<string name="NewConversationActivity__unable_to_remove_s">Unable to remove %1$s</string>
<!-- Dialog message when remove target contact is in system contacts -->
<string name="NewConversationActivity__this_person_is_saved_to_your">This person is saved to your device\'s Contacts. Delete them from your Contacts and try again.</string>
<!-- Dialog action to view contact when they can't be removed otherwise -->
<string name="NewConversationActivity__view_contact">View contact</string>
<!-- ContactFilterView -->
<string name="ContactFilterView__search_name_or_number">Search name or number</string>

View file

@ -22,7 +22,8 @@ class ContactSearchSelectionBuilderTest {
Assert.assertTrue(result.where.contains(RecipientDatabase.ContactSearchSelection.SIGNAL_CONTACT))
Assert.assertTrue(result.where.contains(RecipientDatabase.ContactSearchSelection.FILTER_BLOCKED))
Assert.assertArrayEquals(SqlUtil.buildArgs(RecipientDatabase.RegisteredState.REGISTERED.id, 1, 0), result.args)
Assert.assertTrue(result.where.contains(RecipientDatabase.ContactSearchSelection.FILTER_HIDDEN))
Assert.assertArrayEquals(SqlUtil.buildArgs(RecipientDatabase.RegisteredState.REGISTERED.id, 1, 0, 0), result.args)
}
@Test
@ -48,10 +49,12 @@ class ContactSearchSelectionBuilderTest {
Assert.assertTrue(result.where.contains(RecipientDatabase.ContactSearchSelection.NON_SIGNAL_CONTACT))
Assert.assertTrue(result.where.contains(RecipientDatabase.ContactSearchSelection.FILTER_GROUPS))
Assert.assertTrue(result.where.contains(RecipientDatabase.ContactSearchSelection.FILTER_BLOCKED))
Assert.assertTrue(result.where.contains(RecipientDatabase.ContactSearchSelection.FILTER_HIDDEN))
Assert.assertArrayEquals(
SqlUtil.buildArgs(
RecipientDatabase.RegisteredState.REGISTERED.id, 1,
RecipientDatabase.RegisteredState.REGISTERED.id,
0,
0
),
result.args

View file

@ -147,7 +147,8 @@ object RecipientDatabaseTestUtils {
extras,
hasGroupsInCommon,
badges,
false
needsPniSignature = false,
isHidden = false
),
participants,
isReleaseChannel

View file

@ -133,6 +133,10 @@ public final class SignalContactRecord implements SignalRecord {
diff.add("UnregisteredTimestamp");
}
if (isHidden() != that.isHidden()) {
diff.add("Hidden");
}
if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) {
diff.add("UnknownFields");
}
@ -215,6 +219,10 @@ public final class SignalContactRecord implements SignalRecord {
return proto.getUnregisteredAtTimestamp();
}
public boolean isHidden() {
return proto.getHidden();
}
/**
* Returns the same record, but stripped of the PNI field. Only used while PNP is in development.
*/
@ -331,6 +339,11 @@ public final class SignalContactRecord implements SignalRecord {
return this;
}
public Builder setHidden(boolean hidden) {
builder.setHidden(hidden);
return this;
}
private static ContactRecord.Builder parseUnknowns(byte[] serializedUnknowns) {
try {
return ContactRecord.parseFrom(serializedUnknowns).toBuilder();

View file

@ -87,7 +87,8 @@ message ContactRecord {
uint64 mutedUntilTimestamp = 13;
bool hideStory = 14;
uint64 unregisteredAtTimestamp = 16;
// NEXT ID: 17
bool hidden = 19;
// NEXT ID: 20
}
message GroupV1Record {