mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-28 20:45:17 +00:00
fix: add some more contact syncing: nicknames, approved statuses, blocked statuses
This commit is contained in:
parent
b3ae2e967f
commit
c36387175d
@ -7,6 +7,7 @@ import network.loki.messenger.libsession_util.Contacts
|
|||||||
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
||||||
import network.loki.messenger.libsession_util.UserProfile
|
import network.loki.messenger.libsession_util.UserProfile
|
||||||
import network.loki.messenger.libsession_util.util.Conversation
|
import network.loki.messenger.libsession_util.util.Conversation
|
||||||
|
import network.loki.messenger.libsession_util.util.UserPic
|
||||||
import org.session.libsession.avatars.AvatarHelper
|
import org.session.libsession.avatars.AvatarHelper
|
||||||
import org.session.libsession.database.StorageProtocol
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.session.libsession.messaging.BlindedIdMapping
|
import org.session.libsession.messaging.BlindedIdMapping
|
||||||
@ -318,7 +319,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper, private val configF
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update pfp
|
// update pfp
|
||||||
if (userPic == null) {
|
if (userPic == UserPic.DEFAULT) {
|
||||||
// clear picture if userPic is null
|
// clear picture if userPic is null
|
||||||
TextSecurePreferences.setProfileKey(context, null)
|
TextSecurePreferences.setProfileKey(context, null)
|
||||||
ProfileKeyUtil.setEncodedProfileKey(context, null)
|
ProfileKeyUtil.setEncodedProfileKey(context, null)
|
||||||
@ -335,10 +336,23 @@ class Storage(context: Context, helper: SQLCipherOpenHelper, private val configF
|
|||||||
|
|
||||||
private fun updateContacts(contacts: Contacts) {
|
private fun updateContacts(contacts: Contacts) {
|
||||||
val extracted = contacts.all().toList()
|
val extracted = contacts.all().toList()
|
||||||
|
val profileManager = SSKEnvironment.shared.profileManager
|
||||||
extracted.forEach { contact ->
|
extracted.forEach { contact ->
|
||||||
val address = fromSerialized(contact.id)
|
val address = fromSerialized(contact.id)
|
||||||
val recipient = Recipient.from(context, address, false)
|
val recipient = Recipient.from(context, address, false)
|
||||||
setBlocked(listOf(recipient), contact.blocked)
|
setBlocked(listOf(recipient), contact.blocked, fromConfigUpdate = true)
|
||||||
|
setRecipientApproved(recipient, contact.approved)
|
||||||
|
setRecipientApprovedMe(recipient, contact.approvedMe)
|
||||||
|
profileManager.setName(context, recipient, contact.name)
|
||||||
|
profileManager.setNickname(context, recipient, contact.nickname)
|
||||||
|
if (contact.profilePicture != UserPic.DEFAULT) {
|
||||||
|
val (url, key) = contact.profilePicture
|
||||||
|
if (key.size != ProfileKeyUtil.PROFILE_KEY_BYTES) return@forEach
|
||||||
|
profileManager.setProfileKey(context, recipient, key)
|
||||||
|
profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN)
|
||||||
|
profileManager.setProfilePictureURL(context, recipient, url)
|
||||||
|
}
|
||||||
|
Log.d("Loki-DBG", "Updated contact $contact")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,7 +370,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper, private val configF
|
|||||||
getOrCreateThreadIdFor("",null, it)
|
getOrCreateThreadIdFor("",null, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d("Loki-DBG", "Should update thread $threadId")
|
|
||||||
if (threadId >= 0) {
|
if (threadId >= 0) {
|
||||||
markConversationAsRead(threadId, conversation.lastRead)
|
markConversationAsRead(threadId, conversation.lastRead)
|
||||||
updateThread(threadId, false)
|
updateThread(threadId, false)
|
||||||
@ -761,6 +774,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper, private val configF
|
|||||||
|
|
||||||
override fun setContact(contact: Contact) {
|
override fun setContact(contact: Contact) {
|
||||||
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
|
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
|
||||||
|
SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getRecipientForThread(threadId: Long): Recipient? {
|
override fun getRecipientForThread(threadId: Long): Recipient? {
|
||||||
@ -823,7 +837,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper, private val configF
|
|||||||
threadDatabase.setHasSent(threadId, true)
|
threadDatabase.setHasSent(threadId, true)
|
||||||
}
|
}
|
||||||
if (contact.isBlocked == true) {
|
if (contact.isBlocked == true) {
|
||||||
setBlocked(listOf(recipient), true)
|
setBlocked(listOf(recipient), true, fromConfigUpdate = true)
|
||||||
threadDatabase.deleteConversation(threadId)
|
threadDatabase.deleteConversation(threadId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1125,16 +1139,17 @@ class Storage(context: Context, helper: SQLCipherOpenHelper, private val configF
|
|||||||
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
|
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setBlocked(recipients: List<Recipient>, isBlocked: Boolean) {
|
override fun setBlocked(recipients: List<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean) {
|
||||||
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
|
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
|
||||||
recipientDb.setBlocked(recipients, isBlocked)
|
recipientDb.setBlocked(recipients, isBlocked)
|
||||||
recipients.filter { it.isContactRecipient }.forEach { recipient ->
|
recipients.filter { it.isContactRecipient }.forEach { recipient ->
|
||||||
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
|
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
|
||||||
this.blocked = true
|
this.blocked = isBlocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val contactsConfig = configFactory.contacts ?: return
|
val contactsConfig = configFactory.contacts ?: return
|
||||||
if (contactsConfig.needsDump()) {
|
if (contactsConfig.needsPush() && !fromConfigUpdate) {
|
||||||
|
Log.d("Loki-DBG", "Needs to push contacts after blocking ${recipients.map { it.toShortString() }}")
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,6 @@ import org.session.libsession.utilities.recipients.Recipient
|
|||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
|
||||||
import org.thoughtcrime.securesms.mms.GlideApp
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
import org.thoughtcrime.securesms.util.UiModeUtilities
|
import org.thoughtcrime.securesms.util.UiModeUtilities
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@ -131,10 +130,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
|
|||||||
newNickName = nicknameEditText.text.toString()
|
newNickName = nicknameEditText.text.toString()
|
||||||
}
|
}
|
||||||
val publicKey = recipient.address.serialize()
|
val publicKey = recipient.address.serialize()
|
||||||
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
|
val storage = MessagingModuleConfiguration.shared.storage
|
||||||
val contact = contactDB.getContactWithSessionID(publicKey) ?: Contact(publicKey)
|
val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
|
||||||
contact.nickname = newNickName
|
contact.nickname = newNickName
|
||||||
contactDB.setContact(contact)
|
storage.setContact(contact)
|
||||||
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
|
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ class ProfileManager(private val context: Context, private val configFactory: Co
|
|||||||
database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode)
|
database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun contactUpdatedInternal(contact: Contact) {
|
override fun contactUpdatedInternal(contact: Contact) {
|
||||||
val contactConfig = configFactory.contacts ?: return
|
val contactConfig = configFactory.contacts ?: return
|
||||||
contactConfig.upsertContact(contact.sessionID) {
|
contactConfig.upsertContact(contact.sessionID) {
|
||||||
this.name = contact.name.orEmpty()
|
this.name = contact.name.orEmpty()
|
||||||
@ -93,7 +93,7 @@ class ProfileManager(private val context: Context, private val configFactory: Co
|
|||||||
this.profilePicture = UserPic(url, key)
|
this.profilePicture = UserPic(url, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contactConfig.needsDump()) {
|
if (contactConfig.needsPush()) {
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -70,7 +70,10 @@ object ConfigurationMessageUtilities {
|
|||||||
// don't schedule job if we already have one
|
// don't schedule job if we already have one
|
||||||
val ourDestination = Destination.Contact(userPublicKey)
|
val ourDestination = Destination.Contact(userPublicKey)
|
||||||
val currentStorageJob = storage.getConfigSyncJob(ourDestination)
|
val currentStorageJob = storage.getConfigSyncJob(ourDestination)
|
||||||
if (currentStorageJob != null && !(currentStorageJob as ConfigurationSyncJob).isRunning.get()) return Promise.ofFail(NullPointerException("A job is already pending or in progress, don't schedule another job"))
|
if (currentStorageJob != null) {
|
||||||
|
(currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true)
|
||||||
|
return Promise.ofFail(NullPointerException("A job is already pending or in progress, don't schedule another job"))
|
||||||
|
}
|
||||||
val newConfigSync = ConfigurationSyncJob(ourDestination)
|
val newConfigSync = ConfigurationSyncJob(ourDestination)
|
||||||
Log.d("Loki", "Scheduling new ConfigurationSyncJob")
|
Log.d("Loki", "Scheduling new ConfigurationSyncJob")
|
||||||
JobQueue.shared.add(newConfigSync)
|
JobQueue.shared.add(newConfigSync)
|
||||||
|
@ -204,7 +204,7 @@ interface StorageProtocol {
|
|||||||
fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean)
|
fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean)
|
||||||
fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long)
|
fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long)
|
||||||
fun deleteReactions(messageId: Long, mms: Boolean)
|
fun deleteReactions(messageId: Long, mms: Boolean)
|
||||||
fun setBlocked(recipients: List<Recipient>, isBlocked: Boolean)
|
fun setBlocked(recipients: List<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean = false)
|
||||||
fun blockedContacts(): List<Recipient>
|
fun blockedContacts(): List<Recipient>
|
||||||
|
|
||||||
// Shared configs
|
// Shared configs
|
||||||
|
@ -21,18 +21,12 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
|
|||||||
override var failureCount: Int = 0
|
override var failureCount: Int = 0
|
||||||
override val maxFailureCount: Int = 1
|
override val maxFailureCount: Int = 1
|
||||||
|
|
||||||
val isRunning = AtomicBoolean(false)
|
val shouldRunAgain = AtomicBoolean(false)
|
||||||
|
|
||||||
suspend fun wrap(body: suspend ()->Unit) {
|
override suspend fun execute(dispatcherName: String) {
|
||||||
isRunning.set(true)
|
val storage = MessagingModuleConfiguration.shared.storage
|
||||||
body()
|
|
||||||
isRunning.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override suspend fun execute(dispatcherName: String) = wrap {
|
|
||||||
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
|
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
|
||||||
val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
|
val userPublicKey = storage.getUserPublicKey()
|
||||||
val delegate = delegate
|
val delegate = delegate
|
||||||
if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature
|
if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature
|
||||||
// if we haven't enabled the new configs don't run
|
// if we haven't enabled the new configs don't run
|
||||||
@ -47,7 +41,7 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
|
|||||||
|| (destination is Destination.Contact && destination.publicKey != userPublicKey)
|
|| (destination is Destination.Contact && destination.publicKey != userPublicKey)
|
||||||
) {
|
) {
|
||||||
Log.w(TAG, "No need to run config sync job, TODO")
|
Log.w(TAG, "No need to run config sync job, TODO")
|
||||||
return@wrap delegate?.handleJobSucceeded(this, dispatcherName) ?: Unit
|
return delegate?.handleJobSucceeded(this, dispatcherName) ?: Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
// configFactory singleton instance will come in handy for modifying hashes and fetching configs for namespace etc
|
// configFactory singleton instance will come in handy for modifying hashes and fetching configs for namespace etc
|
||||||
@ -61,7 +55,7 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
|
|||||||
).filter { config -> config.needsPush() }
|
).filter { config -> config.needsPush() }
|
||||||
|
|
||||||
// don't run anything if we don't need to push anything
|
// don't run anything if we don't need to push anything
|
||||||
if (configsRequiringPush.isEmpty()) return@wrap delegate.handleJobSucceeded(this, dispatcherName)
|
if (configsRequiringPush.isEmpty()) return delegate.handleJobSucceeded(this, dispatcherName)
|
||||||
|
|
||||||
// allow null results here so the list index matches configsRequiringPush
|
// allow null results here so the list index matches configsRequiringPush
|
||||||
val batchObjects: List<Pair<SharedConfigurationMessage, SnodeAPI.SnodeBatchRequestInfo>?> = configsRequiringPush.map { config ->
|
val batchObjects: List<Pair<SharedConfigurationMessage, SnodeAPI.SnodeBatchRequestInfo>?> = configsRequiringPush.map { config ->
|
||||||
@ -88,7 +82,7 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
|
|||||||
|
|
||||||
if (batchObjects.any { it == null }) {
|
if (batchObjects.any { it == null }) {
|
||||||
// stop running here, something like a signing error occurred
|
// stop running here, something like a signing error occurred
|
||||||
return@wrap delegate.handleJobFailedPermanently(this, dispatcherName, NullPointerException("One or more requests had a null batch request info"))
|
return delegate.handleJobFailedPermanently(this, dispatcherName, NullPointerException("One or more requests had a null batch request info"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val allRequests = mutableListOf<SnodeAPI.SnodeBatchRequestInfo>()
|
val allRequests = mutableListOf<SnodeAPI.SnodeBatchRequestInfo>()
|
||||||
@ -156,9 +150,13 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error performing batch request", e)
|
Log.e(TAG, "Error performing batch request", e)
|
||||||
return@wrap delegate.handleJobFailedPermanently(this, dispatcherName, e)
|
return delegate.handleJobFailedPermanently(this, dispatcherName, e)
|
||||||
}
|
}
|
||||||
delegate.handleJobSucceeded(this, dispatcherName)
|
delegate.handleJobSucceeded(this, dispatcherName)
|
||||||
|
if (shouldRunAgain.get() && storage.getConfigSyncJob(destination) == null) {
|
||||||
|
// reschedule if something has updated since we started this job
|
||||||
|
JobQueue.shared.add(ConfigurationSyncJob(destination))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Destination.destinationPublicKey(): String = when (this) {
|
fun Destination.destinationPublicKey(): String = when (this) {
|
||||||
|
@ -43,7 +43,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
|
|||||||
var isCaughtUp = false
|
var isCaughtUp = false
|
||||||
var configPollingJob: Job? = null
|
var configPollingJob: Job? = null
|
||||||
|
|
||||||
val configDebouncer = WindowDebouncer(3000, debounceTimer)
|
private val configDebouncer = WindowDebouncer(3000, debounceTimer)
|
||||||
|
|
||||||
// region Settings
|
// region Settings
|
||||||
companion object {
|
companion object {
|
||||||
@ -144,9 +144,6 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("Loki-DBG", "Received configs with hashes: ${messages.map { it.second }}")
|
|
||||||
Log.d("Loki-DBG", "Hashes we have for config: ${configFactory.getHashesFor(forConfigObject)}")
|
|
||||||
|
|
||||||
messages.forEach { (envelope, hash) ->
|
messages.forEach { (envelope, hash) ->
|
||||||
try {
|
try {
|
||||||
val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), openGroupServerID = null)
|
val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), openGroupServerID = null)
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
package org.session.libsession.utilities;
|
package org.session.libsession.utilities;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.session.libsignal.utilities.Base64;
|
import org.session.libsignal.utilities.Base64;
|
||||||
import org.session.libsession.utilities.TextSecurePreferences;
|
|
||||||
import org.session.libsession.utilities.Util;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
public class ProfileKeyUtil {
|
public class ProfileKeyUtil {
|
||||||
|
|
||||||
|
public static final int PROFILE_KEY_BYTES = 32;
|
||||||
|
|
||||||
public static synchronized @NonNull byte[] getProfileKey(@NonNull Context context) {
|
public static synchronized @NonNull byte[] getProfileKey(@NonNull Context context) {
|
||||||
try {
|
try {
|
||||||
String encodedProfileKey = TextSecurePreferences.getProfileKey(context);
|
String encodedProfileKey = TextSecurePreferences.getProfileKey(context);
|
||||||
|
|
||||||
if (encodedProfileKey == null) {
|
if (encodedProfileKey == null) {
|
||||||
encodedProfileKey = Util.getSecret(32);
|
encodedProfileKey = Util.getSecret(PROFILE_KEY_BYTES);
|
||||||
TextSecurePreferences.setProfileKey(context, encodedProfileKey);
|
TextSecurePreferences.setProfileKey(context, encodedProfileKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,7 +37,7 @@ public class ProfileKeyUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static synchronized @NonNull String generateEncodedProfileKey(@NonNull Context context) {
|
public static synchronized @NonNull String generateEncodedProfileKey(@NonNull Context context) {
|
||||||
return Util.getSecret(32);
|
return Util.getSecret(PROFILE_KEY_BYTES);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static synchronized void setEncodedProfileKey(@NonNull Context context, @Nullable String key) {
|
public static synchronized void setEncodedProfileKey(@NonNull Context context, @Nullable String key) {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.session.libsession.utilities
|
package org.session.libsession.utilities
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
||||||
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
|
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
@ -33,6 +34,7 @@ class SSKEnvironment(
|
|||||||
fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String)
|
fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String)
|
||||||
fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray?)
|
fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray?)
|
||||||
fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode)
|
fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode)
|
||||||
|
fun contactUpdatedInternal(contact: Contact)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageExpirationManagerProtocol {
|
interface MessageExpirationManagerProtocol {
|
||||||
|
Loading…
Reference in New Issue
Block a user