2020-12-02 06:39:21 +01:00
|
|
|
package org.session.libsession.messaging.sending_receiving
|
|
|
|
|
|
|
|
import nl.komponents.kovenant.Promise
|
|
|
|
import nl.komponents.kovenant.deferred
|
2020-12-10 05:33:57 +01:00
|
|
|
import org.session.libsession.messaging.MessagingConfiguration
|
2020-12-02 06:39:21 +01:00
|
|
|
import org.session.libsession.messaging.jobs.JobQueue
|
2021-03-02 02:24:09 +01:00
|
|
|
import org.session.libsession.messaging.jobs.MessageSendJob
|
2021-01-20 06:29:52 +01:00
|
|
|
import org.session.libsession.messaging.jobs.NotifyPNServerJob
|
2020-12-02 06:39:21 +01:00
|
|
|
import org.session.libsession.messaging.messages.Destination
|
|
|
|
import org.session.libsession.messaging.messages.Message
|
2021-02-10 06:48:03 +01:00
|
|
|
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
|
|
|
|
import org.session.libsession.messaging.messages.control.ConfigurationMessage
|
2021-03-02 02:24:09 +01:00
|
|
|
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
|
|
|
import org.session.libsession.messaging.messages.visible.*
|
|
|
|
import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment
|
|
|
|
import org.session.libsession.messaging.sending_receiving.linkpreview.LinkPreview as SignalLinkPreview
|
|
|
|
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote
|
2020-12-02 06:39:21 +01:00
|
|
|
import org.session.libsession.messaging.opengroups.OpenGroupAPI
|
|
|
|
import org.session.libsession.messaging.opengroups.OpenGroupMessage
|
2021-03-02 02:24:09 +01:00
|
|
|
import org.session.libsession.messaging.threads.Address
|
2020-12-02 06:39:21 +01:00
|
|
|
import org.session.libsession.messaging.utilities.MessageWrapper
|
|
|
|
import org.session.libsession.snode.RawResponsePromise
|
|
|
|
import org.session.libsession.snode.SnodeAPI
|
2021-03-02 02:24:09 +01:00
|
|
|
import org.session.libsession.snode.SnodeConfiguration
|
2020-12-02 06:39:21 +01:00
|
|
|
import org.session.libsession.snode.SnodeMessage
|
2021-01-20 06:29:52 +01:00
|
|
|
import org.session.libsession.utilities.SSKEnvironment
|
2020-12-02 06:39:21 +01:00
|
|
|
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
|
|
|
import org.session.libsignal.service.loki.api.crypto.ProofOfWork
|
2021-01-14 03:20:18 +01:00
|
|
|
import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey
|
2021-03-02 02:24:09 +01:00
|
|
|
import org.session.libsignal.utilities.Base64
|
|
|
|
import org.session.libsignal.utilities.logging.Log
|
2020-12-02 06:39:21 +01:00
|
|
|
|
2020-11-25 02:06:41 +01:00
|
|
|
|
|
|
|
object MessageSender {
|
2021-03-04 04:54:32 +01:00
|
|
|
const val groupSizeLimit = 100
|
2020-12-02 06:39:21 +01:00
|
|
|
|
|
|
|
// Error
|
2021-03-04 04:54:32 +01:00
|
|
|
sealed class Error(val description: String) : Exception() {
|
2020-12-02 06:39:21 +01:00
|
|
|
object InvalidMessage : Error("Invalid message.")
|
|
|
|
object ProtoConversionFailed : Error("Couldn't convert message to proto.")
|
|
|
|
object ProofOfWorkCalculationFailed : Error("Proof of work calculation failed.")
|
2021-01-14 03:20:18 +01:00
|
|
|
object NoUserX25519KeyPair : Error("Couldn't find user X25519 key pair.")
|
|
|
|
object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.")
|
|
|
|
object SigningFailed : Error("Couldn't sign message.")
|
|
|
|
object EncryptionFailed : Error("Couldn't encrypt message.")
|
2020-12-02 06:39:21 +01:00
|
|
|
|
|
|
|
// Closed groups
|
|
|
|
object NoThread : Error("Couldn't find a thread associated with the given group public key.")
|
2021-02-09 04:45:22 +01:00
|
|
|
object NoKeyPair: Error("Couldn't find a private key associated with the given group public key.")
|
2020-12-02 06:39:21 +01:00
|
|
|
object NoPrivateKey : Error("Couldn't find a private key associated with the given group public key.")
|
|
|
|
object InvalidClosedGroupUpdate : Error("Invalid group update.")
|
|
|
|
|
|
|
|
internal val isRetryable: Boolean = when (this) {
|
|
|
|
is InvalidMessage -> false
|
|
|
|
is ProtoConversionFailed -> false
|
|
|
|
is ProofOfWorkCalculationFailed -> false
|
|
|
|
is InvalidClosedGroupUpdate -> false
|
|
|
|
else -> true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Convenience
|
|
|
|
fun send(message: Message, destination: Destination): Promise<Unit, Exception> {
|
|
|
|
if (destination is Destination.OpenGroup) {
|
|
|
|
return sendToOpenGroupDestination(destination, message)
|
|
|
|
}
|
|
|
|
return sendToSnodeDestination(destination, message)
|
|
|
|
}
|
|
|
|
|
|
|
|
// One-on-One Chats & Closed Groups
|
2021-02-10 06:48:03 +01:00
|
|
|
fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false): Promise<Unit, Exception> {
|
2020-12-02 06:39:21 +01:00
|
|
|
val deferred = deferred<Unit, Exception>()
|
|
|
|
val promise = deferred.promise
|
2020-12-10 05:33:57 +01:00
|
|
|
val storage = MessagingConfiguration.shared.storage
|
2020-12-18 06:44:33 +01:00
|
|
|
val userPublicKey = storage.getUserPublicKey()
|
2020-12-02 06:39:21 +01:00
|
|
|
val preconditionFailure = Exception("Destination should not be open groups!")
|
2020-12-18 06:44:33 +01:00
|
|
|
// Set the timestamp, sender and recipient
|
2020-12-02 06:39:21 +01:00
|
|
|
message.sentTimestamp ?: run { message.sentTimestamp = System.currentTimeMillis() } /* Visible messages will already have their sent timestamp set */
|
2021-01-14 03:20:18 +01:00
|
|
|
message.sender = userPublicKey
|
2020-12-02 06:39:21 +01:00
|
|
|
try {
|
|
|
|
when (destination) {
|
|
|
|
is Destination.Contact -> message.recipient = destination.publicKey
|
|
|
|
is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey
|
|
|
|
is Destination.OpenGroup -> throw preconditionFailure
|
|
|
|
}
|
2020-12-18 06:44:33 +01:00
|
|
|
val isSelfSend = (message.recipient == userPublicKey)
|
|
|
|
// Set the failure handler (need it here already for precondition failure handling)
|
|
|
|
fun handleFailure(error: Exception) {
|
|
|
|
handleFailedMessageSend(message, error)
|
|
|
|
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) {
|
2021-03-02 07:22:56 +01:00
|
|
|
SnodeConfiguration.shared.broadcaster.broadcast("messageFailed", message.sentTimestamp!!)
|
2020-12-18 06:44:33 +01:00
|
|
|
}
|
|
|
|
deferred.reject(error)
|
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
// Validate the message
|
2020-12-02 07:06:28 +01:00
|
|
|
if (!message.isValid()) { throw Error.InvalidMessage }
|
2021-02-10 06:48:03 +01:00
|
|
|
// Stop here if this is a self-send, unless it's:
|
|
|
|
// • a configuration message
|
|
|
|
// • a sync message
|
|
|
|
// • a closed group control message of type `new`
|
|
|
|
var isNewClosedGroupControlMessage = false
|
|
|
|
if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = true
|
|
|
|
if (isSelfSend && message !is ConfigurationMessage && !isSyncMessage && !isNewClosedGroupControlMessage) {
|
2020-12-18 06:44:33 +01:00
|
|
|
handleSuccessfulMessageSend(message, destination)
|
|
|
|
deferred.resolve(Unit)
|
|
|
|
return promise
|
|
|
|
}
|
|
|
|
// Attach the user's profile if needed
|
|
|
|
if (message is VisibleMessage) {
|
|
|
|
val displayName = storage.getUserDisplayName()!!
|
|
|
|
val profileKey = storage.getUserProfileKey()
|
|
|
|
val profilePrictureUrl = storage.getUserProfilePictureURL()
|
|
|
|
if (profileKey != null && profilePrictureUrl != null) {
|
|
|
|
message.profile = Profile(displayName, profileKey, profilePrictureUrl)
|
|
|
|
} else {
|
|
|
|
message.profile = Profile(displayName)
|
|
|
|
}
|
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
// Convert it to protobuf
|
|
|
|
val proto = message.toProto() ?: throw Error.ProtoConversionFailed
|
|
|
|
// Serialize the protobuf
|
|
|
|
val plaintext = proto.toByteArray()
|
|
|
|
// Encrypt the serialized protobuf
|
|
|
|
val ciphertext: ByteArray
|
|
|
|
when (destination) {
|
2021-01-11 01:04:37 +01:00
|
|
|
is Destination.Contact -> ciphertext = MessageSenderEncryption.encryptWithSessionProtocol(plaintext, destination.publicKey)
|
2021-01-14 03:20:18 +01:00
|
|
|
is Destination.ClosedGroup -> {
|
2021-01-20 01:18:22 +01:00
|
|
|
val encryptionKeyPair = MessagingConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!!
|
2021-01-14 03:20:18 +01:00
|
|
|
ciphertext = MessageSenderEncryption.encryptWithSessionProtocol(plaintext, encryptionKeyPair.hexEncodedPublicKey)
|
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
is Destination.OpenGroup -> throw preconditionFailure
|
|
|
|
}
|
|
|
|
// Wrap the result
|
|
|
|
val kind: SignalServiceProtos.Envelope.Type
|
|
|
|
val senderPublicKey: String
|
|
|
|
when (destination) {
|
|
|
|
is Destination.Contact -> {
|
|
|
|
kind = SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER
|
|
|
|
senderPublicKey = ""
|
|
|
|
}
|
|
|
|
is Destination.ClosedGroup -> {
|
|
|
|
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_CIPHERTEXT
|
|
|
|
senderPublicKey = destination.groupPublicKey
|
|
|
|
}
|
|
|
|
is Destination.OpenGroup -> throw preconditionFailure
|
|
|
|
}
|
|
|
|
val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
|
|
|
|
// Calculate proof of work
|
2020-12-18 06:44:33 +01:00
|
|
|
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) {
|
2021-03-02 02:24:09 +01:00
|
|
|
SnodeConfiguration.shared.broadcaster.broadcast("calculatingPoW", message.sentTimestamp!!)
|
2020-12-18 06:44:33 +01:00
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
val recipient = message.recipient!!
|
|
|
|
val base64EncodedData = Base64.encodeBytes(wrappedMessage)
|
|
|
|
val timestamp = System.currentTimeMillis()
|
|
|
|
val nonce = ProofOfWork.calculate(base64EncodedData, recipient, timestamp, message.ttl.toInt()) ?: throw Error.ProofOfWorkCalculationFailed
|
|
|
|
// Send the result
|
2021-03-02 07:22:56 +01:00
|
|
|
val snodeMessage = SnodeMessage(recipient, base64EncodedData, message.ttl, timestamp, nonce)
|
2021-03-02 02:24:09 +01:00
|
|
|
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) {
|
|
|
|
SnodeConfiguration.shared.broadcaster.broadcast("sendingMessage", message.sentTimestamp!!)
|
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
SnodeAPI.sendMessage(snodeMessage).success { promises: Set<RawResponsePromise> ->
|
|
|
|
var isSuccess = false
|
|
|
|
val promiseCount = promises.size
|
|
|
|
var errorCount = 0
|
|
|
|
promises.forEach { promise: RawResponsePromise ->
|
|
|
|
promise.success {
|
|
|
|
if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds
|
|
|
|
isSuccess = true
|
2020-12-18 06:44:33 +01:00
|
|
|
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) {
|
2021-03-02 02:24:09 +01:00
|
|
|
SnodeConfiguration.shared.broadcaster.broadcast("messageSent", message.sentTimestamp!!)
|
2020-12-18 06:44:33 +01:00
|
|
|
}
|
2021-02-10 06:48:03 +01:00
|
|
|
handleSuccessfulMessageSend(message, destination, isSyncMessage)
|
|
|
|
var shouldNotify = (message is VisibleMessage && !isSyncMessage)
|
2021-02-17 06:31:43 +01:00
|
|
|
if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) {
|
2020-12-18 06:44:33 +01:00
|
|
|
shouldNotify = true
|
|
|
|
}
|
|
|
|
if (shouldNotify) {
|
|
|
|
val notifyPNServerJob = NotifyPNServerJob(snodeMessage)
|
|
|
|
JobQueue.shared.add(notifyPNServerJob)
|
|
|
|
deferred.resolve(Unit)
|
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
}
|
|
|
|
promise.fail {
|
|
|
|
errorCount += 1
|
|
|
|
if (errorCount != promiseCount) { return@fail } // Only error out if all promises failed
|
2020-12-18 06:44:33 +01:00
|
|
|
handleFailure(it)
|
2020-12-02 06:39:21 +01:00
|
|
|
deferred.reject(it)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}.fail {
|
|
|
|
Log.d("Loki", "Couldn't send message due to error: $it.")
|
|
|
|
deferred.reject(it)
|
|
|
|
}
|
|
|
|
} catch (exception: Exception) {
|
|
|
|
deferred.reject(exception)
|
|
|
|
}
|
|
|
|
return promise
|
|
|
|
}
|
|
|
|
|
|
|
|
// Open Groups
|
|
|
|
fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
|
|
|
|
val deferred = deferred<Unit, Exception>()
|
2020-12-10 05:33:57 +01:00
|
|
|
val storage = MessagingConfiguration.shared.storage
|
2020-12-02 06:39:21 +01:00
|
|
|
val preconditionFailure = Exception("Destination should not be contacts or closed groups!")
|
2020-12-18 06:44:33 +01:00
|
|
|
message.sentTimestamp ?: run { message.sentTimestamp = System.currentTimeMillis() }
|
2020-12-02 06:39:21 +01:00
|
|
|
message.sender = storage.getUserPublicKey()
|
|
|
|
try {
|
|
|
|
val server: String
|
|
|
|
val channel: Long
|
|
|
|
when (destination) {
|
|
|
|
is Destination.Contact -> throw preconditionFailure
|
|
|
|
is Destination.ClosedGroup -> throw preconditionFailure
|
|
|
|
is Destination.OpenGroup -> {
|
|
|
|
message.recipient = "${destination.server}.${destination.channel}"
|
|
|
|
server = destination.server
|
|
|
|
channel = destination.channel
|
|
|
|
}
|
|
|
|
}
|
2020-12-18 06:44:33 +01:00
|
|
|
// Set the failure handler (need it here already for precondition failure handling)
|
2021-01-20 06:29:52 +01:00
|
|
|
fun handleFailure(error: Exception) {
|
2020-12-18 06:44:33 +01:00
|
|
|
handleFailedMessageSend(message, error)
|
|
|
|
deferred.reject(error)
|
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
// Validate the message
|
2020-12-02 07:06:28 +01:00
|
|
|
if (message !is VisibleMessage || !message.isValid()) {
|
2020-12-18 06:44:33 +01:00
|
|
|
handleFailure(Error.InvalidMessage)
|
2020-12-02 06:39:21 +01:00
|
|
|
throw Error.InvalidMessage
|
|
|
|
}
|
|
|
|
// Convert the message to an open group message
|
2020-12-18 06:44:33 +01:00
|
|
|
val openGroupMessage = OpenGroupMessage.from(message, server) ?: kotlin.run {
|
|
|
|
handleFailure(Error.InvalidMessage)
|
|
|
|
throw Error.InvalidMessage
|
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
// Send the result
|
|
|
|
OpenGroupAPI.sendMessage(openGroupMessage, channel, server).success {
|
|
|
|
message.openGroupServerMessageID = it.serverID
|
2020-12-18 06:44:33 +01:00
|
|
|
handleSuccessfulMessageSend(message, destination)
|
2020-12-02 06:39:21 +01:00
|
|
|
deferred.resolve(Unit)
|
|
|
|
}.fail {
|
2020-12-18 06:44:33 +01:00
|
|
|
handleFailure(it)
|
2020-12-02 06:39:21 +01:00
|
|
|
}
|
|
|
|
} catch (exception: Exception) {
|
|
|
|
deferred.reject(exception)
|
|
|
|
}
|
|
|
|
return deferred.promise
|
|
|
|
}
|
|
|
|
|
|
|
|
// Result Handling
|
2021-02-10 06:48:03 +01:00
|
|
|
fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false) {
|
2021-01-20 06:29:52 +01:00
|
|
|
val storage = MessagingConfiguration.shared.storage
|
|
|
|
val messageId = storage.getMessageIdInDatabase(message.sentTimestamp!!, message.sender!!) ?: return
|
2021-02-10 06:48:03 +01:00
|
|
|
// Ignore future self-sends
|
|
|
|
storage.addReceivedMessageTimestamp(message.sentTimestamp!!)
|
|
|
|
// Track the open group server message ID
|
2021-01-20 06:29:52 +01:00
|
|
|
if (message.openGroupServerMessageID != null) {
|
|
|
|
storage.setOpenGroupServerMessageID(messageId, message.openGroupServerMessageID!!)
|
|
|
|
}
|
2021-02-10 06:48:03 +01:00
|
|
|
// Mark the message as sent
|
2021-03-02 02:24:09 +01:00
|
|
|
storage.markAsSent(message.sentTimestamp!!, message.sender!!)
|
|
|
|
storage.markUnidentified(message.sentTimestamp!!, message.sender!!)
|
2021-02-10 06:48:03 +01:00
|
|
|
// Start the disappearing messages timer if needed
|
2021-03-02 02:24:09 +01:00
|
|
|
SSKEnvironment.shared.messageExpirationManager.startAnyExpiration(message.sentTimestamp!!, message.sender!!)
|
2021-02-10 06:48:03 +01:00
|
|
|
// Sync the message if:
|
|
|
|
// • it's a visible message
|
|
|
|
// • the destination was a contact
|
|
|
|
// • we didn't sync it already
|
|
|
|
val userPublicKey = storage.getUserPublicKey()!!
|
2021-03-02 02:24:09 +01:00
|
|
|
if (destination is Destination.Contact && !isSyncMessage) {
|
|
|
|
if (message is VisibleMessage) { message.syncTarget = destination.publicKey }
|
|
|
|
if (message is ExpirationTimerUpdate) { message.syncTarget = destination.publicKey }
|
|
|
|
sendToSnodeDestination(Destination.Contact(userPublicKey), message, true)
|
2021-02-10 06:48:03 +01:00
|
|
|
}
|
2020-12-02 06:39:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
fun handleFailedMessageSend(message: Message, error: Exception) {
|
2021-01-20 06:37:02 +01:00
|
|
|
val storage = MessagingConfiguration.shared.storage
|
2021-03-02 04:13:12 +01:00
|
|
|
storage.setErrorMessage(message.sentTimestamp!!, message.sender!!, error)
|
2021-03-02 02:24:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// Convenience
|
|
|
|
@JvmStatic
|
|
|
|
fun send(message: VisibleMessage, address: Address, attachments: List<SignalAttachment>, quote: SignalQuote?, linkPreview: SignalLinkPreview?) {
|
2021-03-04 04:03:18 +01:00
|
|
|
val attachmentIDs = MessagingConfiguration.shared.messageDataProvider.getAttachmentIDsFor(message.id!!)
|
|
|
|
message.attachmentIDs.addAll(attachmentIDs)
|
2021-03-02 02:24:09 +01:00
|
|
|
message.quote = Quote.from(quote)
|
|
|
|
message.linkPreview = LinkPreview.from(linkPreview)
|
|
|
|
send(message, address)
|
|
|
|
}
|
|
|
|
|
|
|
|
@JvmStatic
|
|
|
|
fun send(message: Message, address: Address) {
|
|
|
|
val threadID = MessagingConfiguration.shared.storage.getOrCreateThreadIdFor(address)
|
|
|
|
message.threadID = threadID
|
|
|
|
val destination = Destination.from(address)
|
|
|
|
val job = MessageSendJob(message, destination)
|
|
|
|
JobQueue.shared.add(job)
|
|
|
|
}
|
|
|
|
|
|
|
|
fun sendNonDurably(message: VisibleMessage, attachments: List<SignalAttachment>, address: Address): Promise<Unit, Exception> {
|
2021-03-04 04:03:18 +01:00
|
|
|
val attachmentIDs = MessagingConfiguration.shared.messageDataProvider.getAttachmentIDsFor(message.id!!)
|
|
|
|
message.attachmentIDs.addAll(attachmentIDs)
|
2021-03-02 02:24:09 +01:00
|
|
|
return sendNonDurably(message, address)
|
|
|
|
}
|
|
|
|
|
|
|
|
fun sendNonDurably(message: Message, address: Address): Promise<Unit, Exception> {
|
|
|
|
val threadID = MessagingConfiguration.shared.storage.getOrCreateThreadIdFor(address)
|
|
|
|
message.threadID = threadID
|
|
|
|
val destination = Destination.from(address)
|
|
|
|
return send(message, destination)
|
2020-12-02 06:39:21 +01:00
|
|
|
}
|
2021-03-04 04:54:32 +01:00
|
|
|
|
|
|
|
// Closed groups
|
|
|
|
fun createClosedGroup(name: String, members: Collection<String>): Promise<String, Exception> {
|
|
|
|
return create(name, members)
|
|
|
|
}
|
|
|
|
|
|
|
|
fun explicitNameChange(groupPublicKey: String, newName: String) {
|
|
|
|
return setName(groupPublicKey, newName)
|
|
|
|
}
|
|
|
|
|
|
|
|
fun explicitAddMembers(groupPublicKey: String, membersToAdd: List<String>) {
|
|
|
|
return addMembers(groupPublicKey, membersToAdd)
|
|
|
|
}
|
|
|
|
|
|
|
|
fun explicitRemoveMembers(groupPublicKey: String, membersToRemove: List<String>) {
|
|
|
|
return removeMembers(groupPublicKey, membersToRemove)
|
|
|
|
}
|
|
|
|
|
|
|
|
@JvmStatic
|
|
|
|
fun explicitLeave(groupPublicKey: String): Promise<Unit, Exception> {
|
|
|
|
return leave(groupPublicKey)
|
|
|
|
}
|
2020-11-25 02:06:41 +01:00
|
|
|
}
|