Consolidate profile update operations to improve large batch fetching.

This commit is contained in:
Cody Henthorne
2026-03-06 12:44:17 -05:00
committed by jeffrey-signal
parent 02ce6c62a8
commit 7aca5f77f6
2 changed files with 199 additions and 140 deletions

View File

@@ -1945,6 +1945,59 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
/**
* Applies multiple profile fields in a single UPDATE statement. Calls [rotateStorageId] and
* [notifyRecipientChanged] at most once. Designed for bulk profile fetches.
*/
fun applyProfileUpdate(id: RecipientId, update: ProfileUpdate) {
val contentValues = ContentValues().apply {
update.profileName?.let {
put(PROFILE_GIVEN_NAME, it.givenName.nullIfBlank())
put(PROFILE_FAMILY_NAME, it.familyName.nullIfBlank())
put(PROFILE_JOINED_NAME, it.toString().nullIfBlank())
}
update.about?.let { (aboutText, emoji) ->
put(ABOUT, aboutText)
put(ABOUT_EMOJI, emoji)
}
update.badges?.let {
val badgeList = BadgeList(badges = it.map { badge -> toDatabaseBadge(badge) })
put(BADGES, badgeList.encode())
}
update.capabilities?.let {
put(CAPABILITIES, maskCapabilitiesToLong(it))
}
update.sealedSenderAccessMode?.let {
put(SEALED_SENDER_MODE, it.mode)
}
update.phoneNumberSharing?.let {
put(PHONE_NUMBER_SHARING, it.id)
}
update.expiringProfileKeyCredential?.let { (profileKey, credential) ->
val columnData = ExpiringProfileKeyCredentialColumnData.Builder()
.profileKey(profileKey.serialize().toByteString())
.expiringProfileKeyCredential(credential.serialize().toByteString())
.build()
put(EXPIRING_PROFILE_KEY_CREDENTIAL, Base64.encodeWithPadding(columnData.encode()))
}
if (update.clearUsername) {
putNull(USERNAME)
}
}
if (contentValues.size() == 0) {
return
}
if (update(id, contentValues)) {
val needsStorageRotation = update.profileName != null || update.clearUsername
if (needsStorageRotation) {
rotateStorageId(id)
}
AppDependencies.databaseObserver.notifyRecipientChanged(id)
}
}
fun setProfileName(id: RecipientId, profileName: ProfileName) {
val contentValues = ContentValues(1).apply {
put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank())
@@ -4954,4 +5007,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
class SseWithASinglePniSessionForSelfException(cause: Exception) : IllegalStateException(cause)
class SseWithASinglePniSessionException(cause: Exception) : IllegalStateException(cause)
class SseWithMultiplePniSessionsException(cause: Exception) : IllegalStateException(cause)
data class ProfileUpdate(
val profileName: ProfileName? = null,
val about: Pair<String?, String?>? = null,
val badges: List<Badge>? = null,
val capabilities: SignalServiceProfile.Capabilities? = null,
val sealedSenderAccessMode: SealedSenderAccessMode? = null,
val phoneNumberSharing: PhoneNumberSharingState? = null,
val expiringProfileKeyCredential: Pair<ProfileKey, ExpiringProfileKeyCredential>? = null,
val clearUsername: Boolean = false
)
}

View File

@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.transport.RetryLaterException
import org.thoughtcrime.securesms.util.IdentityUtil
import org.thoughtcrime.securesms.util.ProfileUtil
@@ -167,13 +168,20 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
stopwatch.split("filter")
Log.d(TAG, "Committing updates to " + updatedProfiles.size + " of " + response.successes.size + " retrieved profiles.")
val avatarJobs = mutableListOf<RetrieveProfileAvatarJob>()
updatedProfiles.chunked(150).forEach { list: List<IdProfilePair<RecipientId>> ->
SignalDatabase.runInTransaction {
for (idProfilePair in list) {
process(recipientsById[idProfilePair.id]!!, idProfilePair.profileWithCredential)
process(recipientsById[idProfilePair.id]!!, idProfilePair.profileWithCredential, avatarJobs)
}
}
}
if (updatedProfiles.isNotEmpty()) {
StorageSyncHelper.scheduleSyncForDataChange()
}
if (avatarJobs.isNotEmpty()) {
AppDependencies.jobManager.addAll(avatarJobs)
}
stopwatch.split("process")
SignalDatabase.recipients.markProfilesFetched(successIds, System.currentTimeMillis())
@@ -284,46 +292,57 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
return false
}
private fun process(recipient: Recipient, profileAndCredential: SignalServiceProfileWithCredential) {
private fun process(recipient: Recipient, profileAndCredential: SignalServiceProfileWithCredential, avatarJobs: MutableList<RetrieveProfileAvatarJob>) {
val (profile, expiringCredential) = profileAndCredential
val recipientProfileKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey)
val wroteNewProfileName = setProfileName(recipient, profile.name)
setProfileAbout(recipient, profile.about, profile.aboutEmoji)
setProfileAvatar(recipient, profile.avatar)
setProfileBadges(recipient, profile.badges)
setProfileCapabilities(recipient, profile.capabilities)
setUnidentifiedAccessMode(recipient, profile.unidentifiedAccess, profile.isUnrestrictedUnidentifiedAccess)
setPhoneNumberSharingMode(recipient, profile.phoneNumberSharing)
val badges = profile.badges?.map { Badges.fromServiceBadge(it) }
val accessMode = deriveUnidentifiedAccessMode(recipientProfileKey, profile.unidentifiedAccess, profile.isUnrestrictedUnidentifiedAccess)
if (recipientProfileKey != null) {
expiringCredential?. let { credential -> setExpiringProfileKeyCredential(recipient, recipientProfileKey, credential) }
}
if (recipient.hasNonUsernameDisplayName(context) || wroteNewProfileName) {
clearUsername(recipient)
}
}
private fun setProfileBadges(recipient: Recipient, serviceBadges: List<SignalServiceProfile.Badge>?) {
if (serviceBadges == null) {
return
}
val badges = serviceBadges.map { Badges.fromServiceBadge(it) }
if (badges.size != recipient.badges.size) {
if (badges != null && badges.size != recipient.badges.size) {
Log.i(TAG, "Likely change in badges for ${recipient.id}. Going from ${recipient.badges.size} badge(s) to ${badges.size}.")
}
SignalDatabase.recipients.setBadges(recipient.id, badges)
}
if (accessMode != recipient.sealedSenderAccessMode) {
when {
accessMode === SealedSenderAccessMode.UNRESTRICTED -> Log.i(TAG, "Marking recipient UD status as unrestricted.")
recipientProfileKey == null || profile.unidentifiedAccess == null -> Log.i(TAG, "Marking recipient UD status as disabled.")
else -> Log.i(TAG, "Marking recipient UD status as " + accessMode.name + " after verification.")
}
}
private fun setExpiringProfileKeyCredential(
recipient: Recipient,
recipientProfileKey: ProfileKey,
credential: ExpiringProfileKeyCredential
) {
SignalDatabase.recipients.setProfileKeyCredential(recipient.id, recipientProfileKey, credential)
if (recipientProfileKey != null) {
val profileNameResult = resolveProfileName(recipient, recipientProfileKey, profile.name)
val aboutResult = resolveProfileAbout(recipientProfileKey, profile.about, profile.aboutEmoji)
val phoneNumberSharing = resolvePhoneNumberSharing(recipient, recipientProfileKey, profile.phoneNumberSharing)
val clearUsername = recipient.hasNonUsernameDisplayName(context) || profileNameResult?.changed == true
val update = RecipientTable.ProfileUpdate(
profileName = if (profileNameResult?.changed == true) profileNameResult.remoteProfileName else null,
about = aboutResult,
badges = badges,
capabilities = profile.capabilities,
sealedSenderAccessMode = if (accessMode != recipient.sealedSenderAccessMode) accessMode else null,
phoneNumberSharing = phoneNumberSharing,
expiringProfileKeyCredential = expiringCredential?.let { Pair(recipientProfileKey, it) },
clearUsername = clearUsername
)
SignalDatabase.recipients.applyProfileUpdate(recipient.id, update)
profileNameResult?.let { handleProfileNameSideEffects(recipient, it) }
} else {
val update = RecipientTable.ProfileUpdate(
badges = badges,
capabilities = profile.capabilities,
sealedSenderAccessMode = if (accessMode != recipient.sealedSenderAccessMode) accessMode else null
)
SignalDatabase.recipients.applyProfileUpdate(recipient.id, update)
}
if (recipient.profileKey != null && profile.avatar != recipient.profileAvatar) {
avatarJobs += RetrieveProfileAvatarJob(recipient, profile.avatar)
}
}
private fun setIdentityKey(recipient: Recipient, identityKeyValue: String?) {
@@ -347,23 +366,6 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
}
}
private fun setUnidentifiedAccessMode(recipient: Recipient, unidentifiedAccessVerifier: String?, unrestrictedUnidentifiedAccess: Boolean) {
val profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey)
val newMode = deriveUnidentifiedAccessMode(profileKey, unidentifiedAccessVerifier, unrestrictedUnidentifiedAccess)
if (recipient.sealedSenderAccessMode !== newMode) {
if (newMode === SealedSenderAccessMode.UNRESTRICTED) {
Log.i(TAG, "Marking recipient UD status as unrestricted.")
} else if (profileKey == null || unidentifiedAccessVerifier == null) {
Log.i(TAG, "Marking recipient UD status as disabled.")
} else {
Log.i(TAG, "Marking recipient UD status as " + newMode.name + " after verification.")
}
SignalDatabase.recipients.setSealedSenderAccessMode(recipient.id, newMode)
}
}
private fun deriveUnidentifiedAccessMode(profileKey: ProfileKey?, unidentifiedAccessVerifier: String?, unrestrictedUnidentifiedAccess: Boolean): SealedSenderAccessMode {
return if (unrestrictedUnidentifiedAccess && unidentifiedAccessVerifier != null) {
SealedSenderAccessMode.UNRESTRICTED
@@ -386,149 +388,143 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
}
}
private fun setProfileName(recipient: Recipient, profileName: String?): Boolean {
private fun resolveProfileName(recipient: Recipient, profileKey: ProfileKey, encryptedProfileName: String?): ProfileNameResult? {
try {
val profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey) ?: return false
val plaintextProfileName = Util.emptyIfNull(ProfileUtil.decryptString(profileKey, profileName))
val plaintextProfileName = Util.emptyIfNull(ProfileUtil.decryptString(profileKey, encryptedProfileName))
if (plaintextProfileName.isNullOrBlank()) {
if (plaintextProfileName.isBlank()) {
Log.w(TAG, "No name set on the profile for ${recipient.id} -- Leaving it alone")
return false
return null
}
val remoteProfileName = ProfileName.fromSerialized(plaintextProfileName)
val localProfileName = recipient.profileName
val changed = remoteProfileName != localProfileName
if (localProfileName.isEmpty &&
val learnedFirstTime = localProfileName.isEmpty &&
!recipient.isSystemContact &&
recipient.isProfileSharing &&
!recipient.isGroup &&
!recipient.isSelf
) {
val username = SignalDatabase.recipients.getUsername(recipient.id)
val e164 = if (username == null) SignalDatabase.recipients.getE164sForIds(listOf(recipient.id)).firstOrNull() else null
if (username != null || e164 != null) {
Log.i(TAG, "Learned profile name for first time, inserting event")
SignalDatabase.messages.insertLearnedProfileNameChangeMessage(recipient, e164, username)
} else {
Log.w(TAG, "Learned profile name for first time, but do not have username or e164 for ${recipient.id}")
}
var username: String? = null
var e164: String? = null
if (learnedFirstTime) {
username = SignalDatabase.recipients.getUsername(recipient.id)
e164 = if (username == null) SignalDatabase.recipients.getE164sForIds(listOf(recipient.id)).firstOrNull() else null
}
if (remoteProfileName != localProfileName) {
Log.i(TAG, "Profile name updated. Writing new value.")
SignalDatabase.recipients.setProfileName(recipient.id, remoteProfileName)
val remoteDisplayName = remoteProfileName.toString()
val localDisplayName = localProfileName.toString()
val writeChangeEvent = !recipient.isBlocked &&
!recipient.isGroup &&
!recipient.isSelf &&
localDisplayName.isNotEmpty() &&
remoteDisplayName != localDisplayName
if (writeChangeEvent) {
Log.i(TAG, "Writing a profile name change event for ${recipient.id}")
SignalDatabase.messages.insertProfileNameChangeMessages(recipient, remoteDisplayName, localDisplayName)
} else {
Log.i(TAG, "Name changed, but wasn't relevant to write an event. blocked: ${recipient.isBlocked}, group: ${recipient.isGroup}, self: ${recipient.isSelf}, firstSet: ${localDisplayName.isEmpty()}, displayChange: ${remoteDisplayName != localDisplayName}")
}
if (recipient.isIndividual &&
!recipient.isSystemContact &&
!recipient.nickname.isEmpty &&
!recipient.isProfileSharing &&
!recipient.isBlocked &&
!recipient.isSelf &&
!recipient.isHidden
) {
val threadId = SignalDatabase.threads.getThreadIdFor(recipient.id)
if (threadId != null && !RecipientUtil.isMessageRequestAccepted(threadId, recipient)) {
SignalDatabase.nameCollisions.handleIndividualNameCollision(recipient.id)
}
}
if (writeChangeEvent || localDisplayName.isEmpty()) {
AppDependencies.databaseObserver.notifyConversationListListeners()
val threadId = SignalDatabase.threads.getThreadIdFor(recipient.id)
if (threadId != null) {
SignalDatabase.runPostSuccessfulTransaction {
AppDependencies.messageNotifier.updateNotification(context, forConversation(threadId))
}
}
}
return true
return if (changed) {
ProfileNameResult(remoteProfileName, localProfileName, changed = true, learnedFirstTime, username, e164)
} else if (learnedFirstTime) {
ProfileNameResult(remoteProfileName, localProfileName, changed = false, learnedFirstTime, username, e164)
} else {
null
}
} catch (e: InvalidCiphertextException) {
Log.w(TAG, "Bad profile key for ${recipient.id}")
} catch (e: IOException) {
Log.w(TAG, e)
}
return false
return null
}
private fun setProfileAbout(recipient: Recipient, encryptedAbout: String?, encryptedEmoji: String?) {
private fun resolveProfileAbout(profileKey: ProfileKey, encryptedAbout: String?, encryptedEmoji: String?): Pair<String?, String?>? {
try {
val profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey) ?: return
val plaintextAbout = ProfileUtil.decryptString(profileKey, encryptedAbout)
val plaintextEmoji = ProfileUtil.decryptString(profileKey, encryptedEmoji)
SignalDatabase.recipients.setAbout(recipient.id, plaintextAbout, plaintextEmoji)
return Pair(plaintextAbout, plaintextEmoji)
} catch (e: InvalidCiphertextException) {
Log.w(TAG, e)
} catch (e: IOException) {
Log.w(TAG, e)
}
return null
}
private fun clearUsername(recipient: Recipient) {
SignalDatabase.recipients.setUsername(recipient.id, null)
}
private fun setProfileCapabilities(recipient: Recipient, capabilities: SignalServiceProfile.Capabilities?) {
if (capabilities == null) {
return
}
SignalDatabase.recipients.setCapabilities(recipient.id, capabilities)
}
private fun setPhoneNumberSharingMode(recipient: Recipient, phoneNumberSharing: String?) {
val profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey) ?: return
private fun resolvePhoneNumberSharing(recipient: Recipient, profileKey: ProfileKey, phoneNumberSharing: String?): PhoneNumberSharingState? {
try {
val remotePhoneNumberSharing = ProfileUtil.decryptBoolean(profileKey, phoneNumberSharing)
.map { value: Boolean -> if (value) PhoneNumberSharingState.ENABLED else PhoneNumberSharingState.DISABLED }
.orElse(PhoneNumberSharingState.UNKNOWN)
if (recipient.phoneNumberSharing !== remotePhoneNumberSharing) {
return if (recipient.phoneNumberSharing !== remotePhoneNumberSharing) {
Log.i(TAG, "Updating phone number sharing state for " + recipient.id + " to " + remotePhoneNumberSharing)
SignalDatabase.recipients.setPhoneNumberSharing(recipient.id, remotePhoneNumberSharing)
remotePhoneNumberSharing
} else {
null
}
} catch (e: InvalidCiphertextException) {
Log.w(TAG, "Failed to set the phone number sharing setting!", e)
} catch (e: IOException) {
Log.w(TAG, "Failed to set the phone number sharing setting!", e)
}
return null
}
private fun setProfileAvatar(recipient: Recipient, profileAvatar: String?) {
if (recipient.profileKey == null) {
return
private fun handleProfileNameSideEffects(recipient: Recipient, result: ProfileNameResult) {
if (result.learnedFirstTime) {
if (result.username != null || result.e164 != null) {
Log.i(TAG, "Learned profile name for first time, inserting event")
SignalDatabase.messages.insertLearnedProfileNameChangeMessage(recipient, result.e164, result.username)
} else {
Log.w(TAG, "Learned profile name for first time, but do not have username or e164 for ${recipient.id}")
}
}
if (profileAvatar != recipient.profileAvatar) {
SignalDatabase.runPostSuccessfulTransaction(DEDUPE_KEY_RETRIEVE_AVATAR + recipient.id) {
SignalExecutors.BOUNDED.execute {
AppDependencies.jobManager.add(RetrieveProfileAvatarJob(recipient, profileAvatar))
if (result.changed) {
Log.i(TAG, "Profile name updated. Writing new value.")
val remoteDisplayName = result.remoteProfileName.toString()
val localDisplayName = result.localProfileName.toString()
val writeChangeEvent = !recipient.isBlocked &&
!recipient.isGroup &&
!recipient.isSelf &&
localDisplayName.isNotEmpty() &&
remoteDisplayName != localDisplayName
if (writeChangeEvent) {
Log.i(TAG, "Writing a profile name change event for ${recipient.id}")
SignalDatabase.messages.insertProfileNameChangeMessages(recipient, remoteDisplayName, localDisplayName)
} else {
Log.i(TAG, "Name changed, but wasn't relevant to write an event. blocked: ${recipient.isBlocked}, group: ${recipient.isGroup}, self: ${recipient.isSelf}, firstSet: ${localDisplayName.isEmpty()}, displayChange: ${remoteDisplayName != localDisplayName}")
}
if (recipient.isIndividual &&
!recipient.isSystemContact &&
recipient.nickname.isEmpty &&
!recipient.isProfileSharing &&
!recipient.isBlocked &&
!recipient.isSelf &&
!recipient.isHidden
) {
val threadId = SignalDatabase.threads.getThreadIdFor(recipient.id)
if (threadId != null && !RecipientUtil.isMessageRequestAccepted(threadId, recipient)) {
SignalDatabase.nameCollisions.handleIndividualNameCollision(recipient.id)
}
}
if (writeChangeEvent || localDisplayName.isEmpty()) {
AppDependencies.databaseObserver.notifyConversationListListeners()
val threadId = SignalDatabase.threads.getThreadIdFor(recipient.id)
if (threadId != null) {
SignalDatabase.runPostSuccessfulTransaction {
AppDependencies.messageNotifier.updateNotification(context, forConversation(threadId))
}
}
}
}
}
private data class ProfileNameResult(
val remoteProfileName: ProfileName,
val localProfileName: ProfileName,
val changed: Boolean,
val learnedFirstTime: Boolean,
val username: String?,
val e164: String?
)
class Factory : Job.Factory<RetrieveProfileJob?> {
override fun create(parameters: Parameters, serializedData: ByteArray?): RetrieveProfileJob {
val data = JsonJobData.deserialize(serializedData)
@@ -544,7 +540,6 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
private val TAG = Log.tag(RetrieveProfileJob::class.java)
private const val KEY_RECIPIENTS = "recipients"
private const val KEY_SKIP_DEBOUNCE = "skip_debounce"
private const val DEDUPE_KEY_RETRIEVE_AVATAR = KEY + "_RETRIEVE_PROFILE_AVATAR"
private const val QUEUE_PREFIX = "RetrieveProfileJob_"
private val PROFILE_FETCH_DEBOUNCE_TIME_MS = 5.minutes.inWholeMilliseconds