diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 6b32c33f56..6ddef88d7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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? = null, + val badges: List? = null, + val capabilities: SignalServiceProfile.Capabilities? = null, + val sealedSenderAccessMode: SealedSenderAccessMode? = null, + val phoneNumberSharing: PhoneNumberSharingState? = null, + val expiringProfileKeyCredential: Pair? = null, + val clearUsername: Boolean = false + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt index a7ac15fc32..3f62bed044 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt @@ -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() updatedProfiles.chunked(150).forEach { list: List> -> 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) { 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?) { - 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? { 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 { 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