Respect the phoneNumberSharing setting on the profile.

This commit is contained in:
Greyson Parrelli 2023-12-20 11:48:02 -05:00 committed by Clark Chen
parent 624f863da4
commit bb30535afb
24 changed files with 257 additions and 44 deletions

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode
@ -45,6 +46,7 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = if (phoneNumberSharingEnabled) PhoneNumberSharingMode.EVERYBODY else PhoneNumberSharingMode.NOBODY SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = if (phoneNumberSharingEnabled) PhoneNumberSharingMode.EVERYBODY else PhoneNumberSharingMode.NOBODY
SignalDatabase.recipients.markNeedsSync(Recipient.self().id) SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange() StorageSyncHelper.scheduleSyncForDataChange()
ApplicationDependencies.getJobManager().add(ProfileUploadJob())
refresh() refresh()
} }

View file

@ -70,7 +70,11 @@ object BioTextPreference {
} }
} }
override fun getSubhead2Text(): String? = recipient.e164.map(PhoneNumberFormatter::prettyPrint).orElse(null) override fun getSubhead2Text(): String? = if (recipient.shouldShowE164()) {
recipient.e164.map(PhoneNumberFormatter::prettyPrint).orElse(null)
} else {
null
}
override fun areContentsTheSame(newItem: RecipientModel): Boolean { override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return super.areContentsTheSame(newItem) && newItem.recipient.hasSameContent(recipient) return super.areContentsTheSame(newItem) && newItem.recipient.hasSameContent(recipient)

View file

@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -437,8 +436,7 @@ open class ContactSearchAdapter(
number.text = recipient.combinedAboutAndEmoji number.text = recipient.combinedAboutAndEmoji
number.visible = true number.visible = true
} else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.hasE164()) { } else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.hasE164()) {
number.text = PhoneNumberFormatter.prettyPrint(recipient.requireE164()) number.visible = false
number.visible = true
} else { } else {
super.bindNumberField(model) super.bindNumberField(model)
} }

View file

@ -581,7 +581,7 @@ class ConversationAdapterV2(
} else if (isSelf) { } else if (isSelf) {
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation)) conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation))
} else { } else {
val subtitle: String? = recipient.e164.map { e164: String? -> PhoneNumberFormatter.prettyPrint(e164!!) }.orElse(null) val subtitle: String? = recipient.takeIf { it.shouldShowE164() }?.e164?.map { e164: String? -> PhoneNumberFormatter.prettyPrint(e164!!) }?.orElse(null)
if (subtitle == null || subtitle == title) { if (subtitle == null || subtitle == title) {
conversationBanner.hideSubtitle() conversationBanner.hideSubtitle()
} else { } else {

View file

@ -177,6 +177,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val BADGES = "badges" const val BADGES = "badges"
const val NEEDS_PNI_SIGNATURE = "needs_pni_signature" const val NEEDS_PNI_SIGNATURE = "needs_pni_signature"
const val REPORTING_TOKEN = "reporting_token" const val REPORTING_TOKEN = "reporting_token"
const val PHONE_NUMBER_SHARING = "phone_number_sharing"
const val SEARCH_PROFILE_NAME = "search_signal_profile" const val SEARCH_PROFILE_NAME = "search_signal_profile"
const val SORT_NAME = "sort_name" const val SORT_NAME = "sort_name"
@ -242,7 +243,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
$CUSTOM_CHAT_COLORS_ID INTEGER DEFAULT 0, $CUSTOM_CHAT_COLORS_ID INTEGER DEFAULT 0,
$BADGES BLOB DEFAULT NULL, $BADGES BLOB DEFAULT NULL,
$NEEDS_PNI_SIGNATURE INTEGER DEFAULT 0, $NEEDS_PNI_SIGNATURE INTEGER DEFAULT 0,
$REPORTING_TOKEN BLOB DEFAULT NULL $REPORTING_TOKEN BLOB DEFAULT NULL,
$PHONE_NUMBER_SHARING INTEGER DEFAULT ${PhoneNumberSharingState.UNKNOWN.id}
) )
""" """
@ -301,7 +303,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
CUSTOM_CHAT_COLORS_ID, CUSTOM_CHAT_COLORS_ID,
BADGES, BADGES,
NEEDS_PNI_SIGNATURE, NEEDS_PNI_SIGNATURE,
REPORTING_TOKEN REPORTING_TOKEN,
PHONE_NUMBER_SHARING
) )
private val ID_PROJECTION = arrayOf(ID) private val ID_PROJECTION = arrayOf(ID)
@ -1804,6 +1807,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
} }
} }
fun setPhoneNumberSharing(id: RecipientId, phoneNumberSharing: PhoneNumberSharingState) {
val contentValues = contentValuesOf(
PHONE_NUMBER_SHARING to phoneNumberSharing.id
)
if (update(id, contentValues)) {
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(id)
}
}
fun resetAllWallpaper() { fun resetAllWallpaper() {
val database = writableDatabase val database = writableDatabase
val selection = SqlUtil.buildArgs(ID, WALLPAPER_URI) val selection = SqlUtil.buildArgs(ID, WALLPAPER_URI)
@ -3339,7 +3351,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
( (
$SORT_NAME GLOB ? OR $SORT_NAME GLOB ? OR
$USERNAME GLOB ? OR $USERNAME GLOB ? OR
$E164 GLOB ? OR ${ContactSearchSelection.E164_SEARCH} OR
$EMAIL GLOB ? $EMAIL GLOB ?
) )
""" """
@ -3360,7 +3372,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
( (
$SORT_NAME GLOB ? OR $SORT_NAME GLOB ? OR
$USERNAME GLOB ? OR $USERNAME GLOB ? OR
$E164 GLOB ? OR ${ContactSearchSelection.E164_SEARCH} OR
$EMAIL GLOB ? $EMAIL GLOB ?
)) ))
""" """
@ -3381,7 +3393,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
AND ( AND (
$SORT_NAME GLOB ? OR $SORT_NAME GLOB ? OR
$USERNAME GLOB ? OR $USERNAME GLOB ? OR
$E164 GLOB ? OR ${ContactSearchSelection.E164_SEARCH} OR
$EMAIL GLOB ? $EMAIL GLOB ?
) )
""" """
@ -4401,16 +4413,17 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0 WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
) )
""" """
val E164_SEARCH = "(($PHONE_NUMBER_SHARING != ${PhoneNumberSharingState.DISABLED.id} OR $SYSTEM_CONTACT_URI NOT NULL) AND $E164 GLOB ?)"
const val FILTER_GROUPS = " AND $GROUP_ID IS NULL" const val FILTER_GROUPS = " AND $GROUP_ID IS NULL"
const val FILTER_ID = " AND $ID != ?" const val FILTER_ID = " AND $ID != ?"
const val FILTER_BLOCKED = " AND $BLOCKED = ?" const val FILTER_BLOCKED = " AND $BLOCKED = ?"
const val FILTER_HIDDEN = " AND $HIDDEN = ?" const val FILTER_HIDDEN = " AND $HIDDEN = ?"
const val NON_SIGNAL_CONTACT = "$REGISTERED != ? AND $SYSTEM_CONTACT_URI NOT NULL AND ($E164 NOT NULL OR $EMAIL NOT NULL)" const val NON_SIGNAL_CONTACT = "$REGISTERED != ? AND $SYSTEM_CONTACT_URI NOT NULL AND ($E164 NOT NULL OR $EMAIL NOT NULL)"
const val QUERY_NON_SIGNAL_CONTACT = "$NON_SIGNAL_CONTACT AND ($E164 GLOB ? OR $EMAIL GLOB ? OR $SYSTEM_JOINED_NAME GLOB ?)" val QUERY_NON_SIGNAL_CONTACT = "$NON_SIGNAL_CONTACT AND ($E164_SEARCH 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)" const val SIGNAL_CONTACT = "$REGISTERED = ? AND (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)"
const val QUERY_SIGNAL_CONTACT = "$SIGNAL_CONTACT AND ($E164 GLOB ? OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)" val QUERY_SIGNAL_CONTACT = "$SIGNAL_CONTACT AND ($E164_SEARCH OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)"
val GROUP_MEMBER_CONTACT = "$REGISTERED = ? AND $HAS_GROUP_IN_COMMON AND NOT (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)" val GROUP_MEMBER_CONTACT = "$REGISTERED = ? AND $HAS_GROUP_IN_COMMON AND NOT (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)"
val QUERY_GROUP_MEMBER_CONTACT = "$GROUP_MEMBER_CONTACT AND ($E164 GLOB ? OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)" val QUERY_GROUP_MEMBER_CONTACT = "$GROUP_MEMBER_CONTACT AND ($E164_SEARCH OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)"
} }
} }
@ -4500,6 +4513,19 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
} }
} }
enum class PhoneNumberSharingState(val id: Int) {
UNKNOWN(0), ENABLED(1), DISABLED(2);
val enabled
get() = this == ENABLED || this == UNKNOWN
companion object {
fun fromId(id: Int): PhoneNumberSharingState {
return values()[id]
}
}
}
data class CdsV2Result( data class CdsV2Result(
val pni: PNI, val pni: PNI,
val aci: ACI? val aci: ACI?

View file

@ -164,7 +164,8 @@ object RecipientTableCursorUtil {
badges = parseBadgeList(cursor.requireBlob(RecipientTable.BADGES)), badges = parseBadgeList(cursor.requireBlob(RecipientTable.BADGES)),
needsPniSignature = cursor.requireBoolean(RecipientTable.NEEDS_PNI_SIGNATURE), needsPniSignature = cursor.requireBoolean(RecipientTable.NEEDS_PNI_SIGNATURE),
hiddenState = Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)), hiddenState = Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)),
callLinkRoomId = cursor.requireString(RecipientTable.CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) } callLinkRoomId = cursor.requireString(RecipientTable.CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) },
phoneNumberSharing = cursor.requireInt(RecipientTable.PHONE_NUMBER_SHARING).let { RecipientTable.PhoneNumberSharingState.fromId(it) }
) )
} }

View file

@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V210_FixPniPossible
import org.thoughtcrime.securesms.database.helpers.migration.V211_ReceiptColumnRenames import org.thoughtcrime.securesms.database.helpers.migration.V211_ReceiptColumnRenames
import org.thoughtcrime.securesms.database.helpers.migration.V212_RemoveDistributionListUniqueConstraint import org.thoughtcrime.securesms.database.helpers.migration.V212_RemoveDistributionListUniqueConstraint
import org.thoughtcrime.securesms.database.helpers.migration.V213_FixUsernameInE164Column import org.thoughtcrime.securesms.database.helpers.migration.V213_FixUsernameInE164Column
import org.thoughtcrime.securesms.database.helpers.migration.V214_PhoneNumberSharingColumn
/** /**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@ -144,10 +145,11 @@ object SignalDatabaseMigrations {
210 to V210_FixPniPossibleColumns, 210 to V210_FixPniPossibleColumns,
211 to V211_ReceiptColumnRenames, 211 to V211_ReceiptColumnRenames,
212 to V212_RemoveDistributionListUniqueConstraint, 212 to V212_RemoveDistributionListUniqueConstraint,
213 to V213_FixUsernameInE164Column 213 to V213_FixUsernameInE164Column,
214 to V214_PhoneNumberSharingColumn
) )
const val DATABASE_VERSION = 213 const val DATABASE_VERSION = 214
@JvmStatic @JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {

View file

@ -0,0 +1,19 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
/**
* Adds a phone_number_sharing column to the recipient table.
*/
@Suppress("ClassName")
object V214_PhoneNumberSharingColumn : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE recipient ADD COLUMN phone_number_sharing INTEGER DEFAULT 0")
}
}

View file

@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus
import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState
import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState
import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState import org.thoughtcrime.securesms.database.RecipientTable.VibrateState
@ -76,7 +77,8 @@ data class RecipientRecord(
@get:JvmName("needsPniSignature") @get:JvmName("needsPniSignature")
val needsPniSignature: Boolean, val needsPniSignature: Boolean,
val hiddenState: Recipient.HiddenState, val hiddenState: Recipient.HiddenState,
val callLinkRoomId: CallLinkRoomId? val callLinkRoomId: CallLinkRoomId?,
val phoneNumberSharing: PhoneNumberSharingState
) { ) {
fun e164Only(): Boolean { fun e164Only(): Boolean {

View file

@ -148,6 +148,7 @@ public class RefreshOwnProfileJob extends BaseJob {
setProfileCapabilities(profile.getCapabilities()); setProfileCapabilities(profile.getCapabilities());
setProfileBadges(profile.getBadges()); setProfileBadges(profile.getBadges());
ensureUnidentifiedAccessCorrect(profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess()); ensureUnidentifiedAccessCorrect(profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess());
ensurePhoneNumberSharingIsCorrect(profile.getPhoneNumberSharing());
profileAndCredential.getExpiringProfileKeyCredential() profileAndCredential.getExpiringProfileKeyCredential()
.ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential)); .ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential));
@ -256,6 +257,45 @@ public class RefreshOwnProfileJob extends BaseJob {
} }
} }
/**
* Checks to make sure that our phone number sharing setting matches what's on our profile. If there's a mismatch, we first sync with storage service
* (to limit race conditions between devices) and then upload our profile.
*/
private void ensurePhoneNumberSharingIsCorrect(@Nullable String phoneNumberSharingCiphertext) {
if (phoneNumberSharingCiphertext == null) {
Log.w(TAG, "No phone number sharing is set remotely! Syncing with storage service, then uploading our profile.");
syncWithStorageServiceThenUploadProfile();
return;
}
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
ProfileCipher cipher = new ProfileCipher(profileKey);
try {
RecipientTable.PhoneNumberSharingState remotePhoneNumberSharing = cipher.decryptBoolean(Base64.decode(phoneNumberSharingCiphertext))
.map(value -> value ? RecipientTable.PhoneNumberSharingState.ENABLED : RecipientTable.PhoneNumberSharingState.DISABLED)
.orElse(RecipientTable.PhoneNumberSharingState.UNKNOWN);
if (remotePhoneNumberSharing == RecipientTable.PhoneNumberSharingState.UNKNOWN || remotePhoneNumberSharing.getEnabled() != SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled()) {
Log.w(TAG, "Phone number sharing setting did not match! Syncing with storage service, then uploading our profile.");
syncWithStorageServiceThenUploadProfile();
}
} catch (IOException e) {
Log.w(TAG, "Failed to decode phone number sharing! Syncing with storage service, then uploading our profile.", e);
syncWithStorageServiceThenUploadProfile();
} catch (InvalidCiphertextException e) {
Log.w(TAG, "Failed to decrypt phone number sharing! Syncing with storage service, then uploading our profile.", e);
syncWithStorageServiceThenUploadProfile();
}
}
private void syncWithStorageServiceThenUploadProfile() {
ApplicationDependencies.getJobManager()
.startChain(new StorageSyncJob())
.then(new ProfileUploadJob())
.enqueue();
}
static void checkUsernameIsInSync() { static void checkUsernameIsInSync() {
boolean validated = false; boolean validated = false;

View file

@ -21,6 +21,7 @@ import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.util.Pair; import org.signal.libsignal.protocol.util.Pair;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential; import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.badges.Badges; import org.thoughtcrime.securesms.badges.Badges;
@ -28,6 +29,7 @@ import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState;
import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.RecipientRecord; import org.thoughtcrime.securesms.database.model.RecipientRecord;
@ -411,6 +413,14 @@ public class RetrieveProfileJob extends BaseJob {
return true; return true;
} }
PhoneNumberSharingState remotePhoneNumberSharing = ProfileUtil.decryptBoolean(profileKey, remoteProfile.getPhoneNumberSharing())
.map(value -> value ? PhoneNumberSharingState.ENABLED : PhoneNumberSharingState.DISABLED)
.orElse(PhoneNumberSharingState.UNKNOWN);
if (localRecipientRecord.getPhoneNumberSharing() != remotePhoneNumberSharing) {
return true;
}
return false; return false;
} }
@ -425,6 +435,7 @@ public class RetrieveProfileJob extends BaseJob {
setProfileBadges(recipient, profile.getBadges()); setProfileBadges(recipient, profile.getBadges());
setProfileCapabilities(recipient, profile.getCapabilities()); setProfileCapabilities(recipient, profile.getCapabilities());
setUnidentifiedAccessMode(recipient, profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess()); setUnidentifiedAccessMode(recipient, profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess());
setPhoneNumberSharingMode(recipient, profile.getPhoneNumberSharing());
if (recipientProfileKey != null) { if (recipientProfileKey != null) {
profileAndCredential.getExpiringProfileKeyCredential() profileAndCredential.getExpiringProfileKeyCredential()
@ -611,6 +622,26 @@ public class RetrieveProfileJob extends BaseJob {
SignalDatabase.recipients().setCapabilities(recipient.getId(), capabilities); SignalDatabase.recipients().setCapabilities(recipient.getId(), capabilities);
} }
private void setPhoneNumberSharingMode(Recipient recipient, String phoneNumberSharing) {
ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey());
if (profileKey == null) {
return;
}
try {
PhoneNumberSharingState remotePhoneNumberSharing = ProfileUtil.decryptBoolean(profileKey, phoneNumberSharing)
.map(value -> value ? PhoneNumberSharingState.ENABLED : PhoneNumberSharingState.DISABLED)
.orElse(PhoneNumberSharingState.UNKNOWN);
if (recipient.getPhoneNumberSharing() != remotePhoneNumberSharing) {
Log.i(TAG, "Updating phone number sharing state for " + recipient.getId() + " to " + remotePhoneNumberSharing);
SignalDatabase.recipients().setPhoneNumberSharing(recipient.getId(), remotePhoneNumberSharing);
}
} catch (InvalidCiphertextException | IOException e) {
Log.w(TAG, "Failed to set the phone number sharing setting!", e);
}
}
/** /**
* Collective state as responses are processed as they come in. * Collective state as responses are processed as they come in.
*/ */

View file

@ -2,9 +2,11 @@ package org.thoughtcrime.securesms.profiles.edit.pnp
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.storage.StorageSyncHelper
/** /**
* Manages the current phone-number listing state. * Manages the current phone-number listing state.
@ -31,6 +33,8 @@ class WhoCanFindMeByPhoneNumberRepository {
} }
ApplicationDependencies.getJobManager().add(RefreshAttributesJob()) ApplicationDependencies.getJobManager().add(RefreshAttributesJob())
StorageSyncHelper.scheduleSyncForDataChange()
ApplicationDependencies.getJobManager().add(ProfileUploadJob())
} }
} }
} }

View file

@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting; import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting;
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState;
import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState; import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState;
import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode;
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState; import org.thoughtcrime.securesms.database.RecipientTable.VibrateState;
@ -137,6 +138,7 @@ public class Recipient {
private final boolean needsPniSignature; private final boolean needsPniSignature;
private final CallLinkRoomId callLinkRoomId; private final CallLinkRoomId callLinkRoomId;
private final Optional<GroupRecord> groupRecord; private final Optional<GroupRecord> groupRecord;
private final PhoneNumberSharingState phoneNumberSharing;
/** /**
* Returns a {@link LiveRecipient}, which contains a {@link Recipient} that may or may not be * Returns a {@link LiveRecipient}, which contains a {@link Recipient} that may or may not be
@ -426,6 +428,7 @@ public class Recipient {
this.isActiveGroup = false; this.isActiveGroup = false;
this.callLinkRoomId = null; this.callLinkRoomId = null;
this.groupRecord = Optional.empty(); this.groupRecord = Optional.empty();
this.phoneNumberSharing = PhoneNumberSharingState.UNKNOWN;
} }
public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) {
@ -481,6 +484,7 @@ public class Recipient {
this.isActiveGroup = details.isActiveGroup; this.isActiveGroup = details.isActiveGroup;
this.callLinkRoomId = details.callLinkRoomId; this.callLinkRoomId = details.callLinkRoomId;
this.groupRecord = details.groupRecord; this.groupRecord = details.groupRecord;
this.phoneNumberSharing = details.phoneNumberSharing;
} }
public @NonNull RecipientId getId() { public @NonNull RecipientId getId() {
@ -679,6 +683,13 @@ public class Recipient {
return Optional.ofNullable(e164); return Optional.ofNullable(e164);
} }
/**
* Whether or not we should show this user's e164 in the interface.
*/
public boolean shouldShowE164() {
return hasE164() && (isSystemContact() || getPhoneNumberSharing() != PhoneNumberSharingState.DISABLED);
}
public @NonNull Optional<String> getEmail() { public @NonNull Optional<String> getEmail() {
return Optional.ofNullable(email); return Optional.ofNullable(email);
} }
@ -1230,6 +1241,10 @@ public class Recipient {
return Objects.requireNonNull(callLinkRoomId); return Objects.requireNonNull(callLinkRoomId);
} }
public PhoneNumberSharingState getPhoneNumberSharing() {
return phoneNumberSharing;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;
@ -1386,7 +1401,8 @@ public class Recipient {
hasGroupsInCommon == other.hasGroupsInCommon && hasGroupsInCommon == other.hasGroupsInCommon &&
Objects.equals(badges, other.badges) && Objects.equals(badges, other.badges) &&
isActiveGroup == other.isActiveGroup && isActiveGroup == other.isActiveGroup &&
Objects.equals(callLinkRoomId, other.callLinkRoomId); Objects.equals(callLinkRoomId, other.callLinkRoomId) &&
phoneNumberSharing == other.phoneNumberSharing;
} }
private static boolean allContentsAreTheSame(@NonNull List<Recipient> a, @NonNull List<Recipient> b) { private static boolean allContentsAreTheSame(@NonNull List<Recipient> a, @NonNull List<Recipient> b) {

View file

@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState
import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState
import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode import org.thoughtcrime.securesms.database.RecipientTable.UnidentifiedAccessMode
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState import org.thoughtcrime.securesms.database.RecipientTable.VibrateState
@ -79,7 +80,8 @@ class RecipientDetails private constructor(
@JvmField val isReleaseChannel: Boolean, @JvmField val isReleaseChannel: Boolean,
@JvmField val needsPniSignature: Boolean, @JvmField val needsPniSignature: Boolean,
@JvmField val callLinkRoomId: CallLinkRoomId?, @JvmField val callLinkRoomId: CallLinkRoomId?,
@JvmField val groupRecord: Optional<GroupRecord> @JvmField val groupRecord: Optional<GroupRecord>,
@JvmField val phoneNumberSharing: PhoneNumberSharingState
) { ) {
@VisibleForTesting @VisibleForTesting
@ -143,7 +145,8 @@ class RecipientDetails private constructor(
isReleaseChannel = isReleaseChannel, isReleaseChannel = isReleaseChannel,
needsPniSignature = record.needsPniSignature, needsPniSignature = record.needsPniSignature,
callLinkRoomId = record.callLinkRoomId, callLinkRoomId = record.callLinkRoomId,
groupRecord = groupRecord groupRecord = groupRecord,
phoneNumberSharing = record.phoneNumberSharing
) )
companion object { companion object {
@ -271,7 +274,8 @@ class RecipientDetails private constructor(
needsPniSignature = false, needsPniSignature = false,
isActiveGroup = false, isActiveGroup = false,
callLinkRoomId = null, callLinkRoomId = null,
groupRecord = Optional.empty() groupRecord = Optional.empty(),
phoneNumberSharing = PhoneNumberSharingState.UNKNOWN
) )
} }
} }

View file

@ -34,7 +34,7 @@ public final class RecipientExporter {
} }
private static void addAddressToIntent(Intent intent, Recipient recipient) { private static void addAddressToIntent(Intent intent, Recipient recipient) {
if (recipient.getE164().isPresent()) { if (recipient.getE164().isPresent() && recipient.shouldShowE164()) {
intent.putExtra(ContactsContract.Intents.Insert.PHONE, recipient.requireE164()); intent.putExtra(ContactsContract.Intents.Insert.PHONE, recipient.requireE164());
} else if (recipient.getEmail().isPresent()) { } else if (recipient.getEmail().isPresent()) {
intent.putExtra(ContactsContract.Intents.Insert.EMAIL, recipient.requireEmail()); intent.putExtra(ContactsContract.Intents.Insert.EMAIL, recipient.requireEmail());

View file

@ -154,7 +154,7 @@ private fun AboutSheetContent(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (recipient.about != null) { if (!recipient.about.isNullOrBlank()) {
AboutRow( AboutRow(
startIcon = painterResource(R.drawable.symbol_edit_24), startIcon = painterResource(R.drawable.symbol_edit_24),
text = { text = {
@ -190,7 +190,7 @@ private fun AboutSheetContent(
) )
} }
if (recipient.e164.isPresent) { if (recipient.e164.isPresent && recipient.shouldShowE164()) {
val e164 = remember(recipient.e164.get()) { val e164 = remember(recipient.e164.get()) {
PhoneNumberFormatter.get(context).prettyPrintFormat(recipient.e164.get()) PhoneNumberFormatter.get(context).prettyPrintFormat(recipient.e164.get())
} }

View file

@ -213,7 +213,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
about.setVisibility(View.GONE); about.setVisibility(View.GONE);
} }
String usernameNumberString = recipient.hasAUserSetDisplayName(requireContext()) && !recipient.isSelf() String usernameNumberString = recipient.hasAUserSetDisplayName(requireContext()) && !recipient.isSelf() && recipient.shouldShowE164()
? recipient.getSmsAddress().map(PhoneNumberFormatter::prettyPrint).orElse("").trim() ? recipient.getSmsAddress().map(PhoneNumberFormatter::prettyPrint).orElse("").trim()
: ""; : "";
usernameNumber.setText(usernameNumberString); usernameNumber.setText(usernameNumberString);

View file

@ -150,6 +150,17 @@ public final class ProfileUtil {
return decryptString(profileKey, Base64.decode(encryptedStringBase64)); return decryptString(profileKey, Base64.decode(encryptedStringBase64));
} }
public static Optional<Boolean> decryptBoolean(@NonNull ProfileKey profileKey, @Nullable String encryptedBooleanBase64)
throws InvalidCiphertextException, IOException
{
if (encryptedBooleanBase64 == null) {
return Optional.empty();
}
ProfileCipher profileCipher = new ProfileCipher(profileKey);
return profileCipher.decryptBoolean(Base64.decode(encryptedBooleanBase64));
}
@WorkerThread @WorkerThread
public static @NonNull MobileCoinPublicAddress getAddressForRecipient(@NonNull Recipient recipient) public static @NonNull MobileCoinPublicAddress getAddressForRecipient(@NonNull Recipient recipient)
throws IOException, PaymentsAddressException throws IOException, PaymentsAddressException
@ -347,7 +358,8 @@ public final class ProfileUtil {
aboutEmoji, aboutEmoji,
Optional.ofNullable(paymentsAddress), Optional.ofNullable(paymentsAddress),
avatar, avatar,
badgeIds).orElse(null); badgeIds,
SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled()).orElse(null);
SignalStore.registrationValues().markHasUploadedProfile(); SignalStore.registrationValues().markHasUploadedProfile();
if (!avatar.keepTheSame) { if (!avatar.keepTheSame) {
SignalDatabase.recipients().setProfileAvatar(Recipient.self().getId(), avatarPath); SignalDatabase.recipients().setProfileAvatar(Recipient.self().getId(), avatarPath);

View file

@ -146,7 +146,8 @@ object RecipientDatabaseTestUtils {
badges = badges, badges = badges,
needsPniSignature = false, needsPniSignature = false,
hiddenState = Recipient.HiddenState.NOT_HIDDEN, hiddenState = Recipient.HiddenState.NOT_HIDDEN,
callLinkRoomId = null callLinkRoomId = null,
phoneNumberSharing = RecipientTable.PhoneNumberSharingState.UNKNOWN
), ),
participantIds = participants, participantIds = participants,
isReleaseChannel = isReleaseChannel, isReleaseChannel = isReleaseChannel,

View file

@ -37,6 +37,19 @@ public final class RecipientExporterTest {
assertNull(intent.getStringExtra(EMAIL)); assertNull(intent.getStringExtra(EMAIL));
} }
@Test
public void asAddContactIntent_with_phone_number_should_not_show_number() {
Recipient recipient = givenPhoneRecipient(ProfileName.fromParts("Alice", null), "+1555123456", false);
Intent intent = RecipientExporter.export(recipient).asAddContactIntent();
assertEquals(Intent.ACTION_INSERT_OR_EDIT, intent.getAction());
assertEquals(ContactsContract.Contacts.CONTENT_ITEM_TYPE, intent.getType());
assertEquals("Alice", intent.getStringExtra(NAME));
assertNull(intent.getStringExtra(PHONE));
assertNull(intent.getStringExtra(EMAIL));
}
@Test @Test
public void asAddContactIntent_with_email() { public void asAddContactIntent_with_email() {
Recipient recipient = givenEmailRecipient(ProfileName.fromParts("Bob", null), "bob@signal.org"); Recipient recipient = givenEmailRecipient(ProfileName.fromParts("Bob", null), "bob@signal.org");
@ -50,13 +63,19 @@ public final class RecipientExporterTest {
assertNull(intent.getStringExtra(PHONE)); assertNull(intent.getStringExtra(PHONE));
} }
private Recipient givenPhoneRecipient(ProfileName profileName, String phone) { private Recipient givenPhoneRecipient(ProfileName profileName, String phone) {
return givenPhoneRecipient(profileName, phone, true);
}
private Recipient givenPhoneRecipient(ProfileName profileName, String phone, boolean shouldShowPhoneNumber) {
Recipient recipient = mock(Recipient.class); Recipient recipient = mock(Recipient.class);
when(recipient.getProfileName()).thenReturn(profileName); when(recipient.getProfileName()).thenReturn(profileName);
when(recipient.requireE164()).thenReturn(phone); when(recipient.requireE164()).thenReturn(phone);
when(recipient.getE164()).thenAnswer(i -> Optional.of(phone)); when(recipient.getE164()).thenAnswer(i -> Optional.of(phone));
when(recipient.getEmail()).thenAnswer(i -> Optional.empty()); when(recipient.getEmail()).thenAnswer(i -> Optional.empty());
when(recipient.shouldShowE164()).thenAnswer(i -> shouldShowPhoneNumber);
return recipient; return recipient;
} }

View file

@ -722,17 +722,19 @@ public class SignalServiceAccountManager {
String aboutEmoji, String aboutEmoji,
Optional<PaymentAddress> paymentsAddress, Optional<PaymentAddress> paymentsAddress,
AvatarUploadParams avatar, AvatarUploadParams avatar,
List<String> visibleBadgeIds) List<String> visibleBadgeIds,
boolean phoneNumberSharing)
throws IOException throws IOException
{ {
if (name == null) name = ""; if (name == null) name = "";
ProfileCipher profileCipher = new ProfileCipher(profileKey); ProfileCipher profileCipher = new ProfileCipher(profileKey);
byte[] ciphertextName = profileCipher.encryptString(name, ProfileCipher.getTargetNameLength(name)); byte[] ciphertextName = profileCipher.encryptString(name, ProfileCipher.getTargetNameLength(name));
byte[] ciphertextAbout = profileCipher.encryptString(about, ProfileCipher.getTargetAboutLength(about)); byte[] ciphertextAbout = profileCipher.encryptString(about, ProfileCipher.getTargetAboutLength(about));
byte[] ciphertextEmoji = profileCipher.encryptString(aboutEmoji, ProfileCipher.EMOJI_PADDED_LENGTH); byte[] ciphertextEmoji = profileCipher.encryptString(aboutEmoji, ProfileCipher.EMOJI_PADDED_LENGTH);
byte[] ciphertextMobileCoinAddress = paymentsAddress.map(address -> profileCipher.encryptWithLength(address.encode(), ProfileCipher.PAYMENTS_ADDRESS_CONTENT_SIZE)).orElse(null); byte[] ciphertextMobileCoinAddress = paymentsAddress.map(address -> profileCipher.encryptWithLength(address.encode(), ProfileCipher.PAYMENTS_ADDRESS_CONTENT_SIZE)).orElse(null);
ProfileAvatarData profileAvatarData = null; byte[] cipherTextPhoneNumberSharing = profileCipher.encryptBoolean(phoneNumberSharing);
ProfileAvatarData profileAvatarData = null;
if (avatar.stream != null && !avatar.keepTheSame) { if (avatar.stream != null && !avatar.keepTheSame) {
profileAvatarData = new ProfileAvatarData(avatar.stream.getStream(), profileAvatarData = new ProfileAvatarData(avatar.stream.getStream(),
@ -746,6 +748,7 @@ public class SignalServiceAccountManager {
ciphertextAbout, ciphertextAbout,
ciphertextEmoji, ciphertextEmoji,
ciphertextMobileCoinAddress, ciphertextMobileCoinAddress,
cipherTextPhoneNumberSharing,
avatar.hasAvatar, avatar.hasAvatar,
avatar.keepTheSame, avatar.keepTheSame,
profileKey.getCommitment(aci.getLibSignalAci()).serialize(), profileKey.getCommitment(aci.getLibSignalAci()).serialize(),

View file

@ -14,7 +14,9 @@ import java.security.InvalidKeyException;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Locale; import java.util.Locale;
import java.util.Optional;
import javax.annotation.Nullable;
import javax.crypto.BadPaddingException; import javax.crypto.BadPaddingException;
import javax.crypto.Cipher; import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException; import javax.crypto.IllegalBlockSizeException;
@ -127,6 +129,22 @@ public class ProfileCipher {
return new String(plaintext); return new String(plaintext);
} }
public byte[] encryptBoolean(boolean input) {
byte[] value = new byte[1];
value[0] = (byte) (input ? 1 : 0);
return encrypt(value, value.length);
}
public Optional<Boolean> decryptBoolean(@Nullable byte[] input) throws InvalidCiphertextException {
if (input == null) {
return Optional.empty();
}
byte[] paddedPlaintext = decrypt(input);
return Optional.of(paddedPlaintext[0] != 0);
}
/** /**
* Encodes the length, and adds padding. * Encodes the length, and adds padding.
* <p> * <p>

View file

@ -63,6 +63,9 @@ public class SignalServiceProfile {
@JsonProperty @JsonProperty
private List<Badge> badges; private List<Badge> badges;
@JsonProperty
private String phoneNumberSharing;
@JsonIgnore @JsonIgnore
private RequestType requestType; private RequestType requestType;
@ -96,6 +99,10 @@ public class SignalServiceProfile {
return unidentifiedAccess; return unidentifiedAccess;
} }
public String getPhoneNumberSharing() {
return phoneNumberSharing;
}
public boolean isUnrestrictedUnidentifiedAccess() { public boolean isUnrestrictedUnidentifiedAccess() {
return unrestrictedUnidentifiedAccess; return unrestrictedUnidentifiedAccess;
} }

View file

@ -22,6 +22,9 @@ public class SignalServiceProfileWrite {
@JsonProperty @JsonProperty
private byte[] paymentAddress; private byte[] paymentAddress;
@JsonProperty
private byte[] phoneNumberSharing;
@JsonProperty @JsonProperty
private boolean avatar; private boolean avatar;
@ -38,16 +41,17 @@ public class SignalServiceProfileWrite {
public SignalServiceProfileWrite(){ public SignalServiceProfileWrite(){
} }
public SignalServiceProfileWrite(String version, byte[] name, byte[] about, byte[] aboutEmoji, byte[] paymentAddress, boolean hasAvatar, boolean sameAvatar, byte[] commitment, List<String> badgeIds) { public SignalServiceProfileWrite(String version, byte[] name, byte[] about, byte[] aboutEmoji, byte[] paymentAddress, byte[] phoneNumberSharing, boolean hasAvatar, boolean sameAvatar, byte[] commitment, List<String> badgeIds) {
this.version = version; this.version = version;
this.name = name; this.name = name;
this.about = about; this.about = about;
this.aboutEmoji = aboutEmoji; this.aboutEmoji = aboutEmoji;
this.paymentAddress = paymentAddress; this.paymentAddress = paymentAddress;
this.avatar = hasAvatar; this.phoneNumberSharing = phoneNumberSharing;
this.sameAvatar = sameAvatar; this.avatar = hasAvatar;
this.commitment = commitment; this.sameAvatar = sameAvatar;
this.badgeIds = badgeIds; this.commitment = commitment;
this.badgeIds = badgeIds;
} }
public boolean hasAvatar() { public boolean hasAvatar() {