package org.thoughtcrime.securesms.loki.api import android.content.Context import android.os.Handler import android.util.Log import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.then import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.Address import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.jobs.PushDecryptJob import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob import org.thoughtcrime.securesms.loki.protocol.SessionMetaProtocol import org.thoughtcrime.securesms.loki.utilities.successBackground import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.libsignal.util.guava.Optional import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer import org.whispersystems.signalservice.api.messages.SignalServiceContent import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage import org.whispersystems.signalservice.api.messages.SignalServiceGroup import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage import org.whispersystems.signalservice.api.push.SignalServiceAddress import org.whispersystems.signalservice.loki.api.fileserver.FileServerAPI import org.whispersystems.signalservice.loki.api.opengroups.PublicChat import org.whispersystems.signalservice.loki.api.opengroups.PublicChatAPI import org.whispersystems.signalservice.loki.api.opengroups.PublicChatMessage import org.whispersystems.signalservice.loki.protocol.shelved.multidevice.MultiDeviceProtocol import java.security.MessageDigest import java.util.* class PublicChatPoller(private val context: Context, private val group: PublicChat) { private val handler = Handler() private var hasStarted = false public var isCaughtUp = false // region Convenience private val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context) private var displayNameUpdatees = setOf() private val api: PublicChatAPI get() = { val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize() val lokiAPIDatabase = DatabaseFactory.getLokiAPIDatabase(context) val lokiUserDatabase = DatabaseFactory.getLokiUserDatabase(context) PublicChatAPI(userHexEncodedPublicKey, userPrivateKey, lokiAPIDatabase, lokiUserDatabase) }() // endregion // region Tasks private val pollForNewMessagesTask = object : Runnable { override fun run() { pollForNewMessages() handler.postDelayed(this, pollForNewMessagesInterval) } } private val pollForDeletedMessagesTask = object : Runnable { override fun run() { pollForDeletedMessages() handler.postDelayed(this, pollForDeletedMessagesInterval) } } private val pollForModeratorsTask = object : Runnable { override fun run() { pollForModerators() handler.postDelayed(this, pollForModeratorsInterval) } } private val pollForDisplayNamesTask = object : Runnable { override fun run() { pollForDisplayNames() handler.postDelayed(this, pollForDisplayNamesInterval) } } // endregion // region Settings companion object { private val pollForNewMessagesInterval: Long = 4 * 1000 private val pollForDeletedMessagesInterval: Long = 60 * 1000 private val pollForModeratorsInterval: Long = 10 * 60 * 1000 private val pollForDisplayNamesInterval: Long = 60 * 1000 } // endregion // region Lifecycle fun startIfNeeded() { if (hasStarted) return pollForNewMessagesTask.run() pollForDeletedMessagesTask.run() pollForModeratorsTask.run() pollForDisplayNamesTask.run() hasStarted = true } fun stop() { handler.removeCallbacks(pollForNewMessagesTask) handler.removeCallbacks(pollForDeletedMessagesTask) handler.removeCallbacks(pollForModeratorsTask) handler.removeCallbacks(pollForDisplayNamesTask) hasStarted = false } // endregion // region Polling private fun getDataMessage(message: PublicChatMessage): SignalServiceDataMessage { val id = group.id.toByteArray() val serviceGroup = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, SignalServiceGroup.GroupType.PUBLIC_CHAT, null, null, null, null) val quote = if (message.quote != null) { SignalServiceDataMessage.Quote(message.quote!!.quotedMessageTimestamp, SignalServiceAddress(message.quote!!.quoteePublicKey), message.quote!!.quotedMessageBody, listOf()) } else { null } val attachments = message.attachments.mapNotNull { attachment -> if (attachment.kind != PublicChatMessage.Attachment.Kind.Attachment) { return@mapNotNull null } SignalServiceAttachmentPointer( attachment.serverID, attachment.contentType, ByteArray(0), Optional.of(attachment.size), Optional.absent(), attachment.width, attachment.height, Optional.absent(), Optional.of(attachment.fileName), false, Optional.fromNullable(attachment.caption), attachment.url) } val linkPreview = message.attachments.firstOrNull { it.kind == PublicChatMessage.Attachment.Kind.LinkPreview } val signalLinkPreviews = mutableListOf() if (linkPreview != null) { val attachment = SignalServiceAttachmentPointer( linkPreview.serverID, linkPreview.contentType, ByteArray(0), Optional.of(linkPreview.size), Optional.absent(), linkPreview.width, linkPreview.height, Optional.absent(), Optional.of(linkPreview.fileName), false, Optional.fromNullable(linkPreview.caption), linkPreview.url) signalLinkPreviews.add(SignalServiceDataMessage.Preview(linkPreview.linkPreviewURL!!, linkPreview.linkPreviewTitle!!, Optional.of(attachment))) } val body = if (message.body == message.timestamp.toString()) "" else message.body // Workaround for the fact that the back-end doesn't accept messages without a body return SignalServiceDataMessage(message.timestamp, serviceGroup, attachments, body, false, 0, false, null, false, quote, null, signalLinkPreviews, null) } fun pollForNewMessages() { fun processIncomingMessage(message: PublicChatMessage) { // If the sender of the current message is not a slave device, set the display name in the database val masterHexEncodedPublicKey = MultiDeviceProtocol.shared.getMasterDevice(message.senderPublicKey) if (masterHexEncodedPublicKey == null) { val senderDisplayName = "${message.displayName} (...${message.senderPublicKey.takeLast(8)})" DatabaseFactory.getLokiUserDatabase(context).setServerDisplayName(group.id, message.senderPublicKey, senderDisplayName) } val senderHexEncodedPublicKey = masterHexEncodedPublicKey ?: message.senderPublicKey val serviceDataMessage = getDataMessage(message) val serviceContent = SignalServiceContent(serviceDataMessage, senderHexEncodedPublicKey, SignalServiceAddress.DEFAULT_DEVICE_ID, message.serverTimestamp, false, false) if (serviceDataMessage.quote.isPresent || (serviceDataMessage.attachments.isPresent && serviceDataMessage.attachments.get().size > 0) || serviceDataMessage.previews.isPresent) { PushDecryptJob(context).handleMediaMessage(serviceContent, serviceDataMessage, Optional.absent(), Optional.of(message.serverID)) } else { PushDecryptJob(context).handleTextMessage(serviceContent, serviceDataMessage, Optional.absent(), Optional.of(message.serverID)) } // Update profile picture if needed val senderAsRecipient = Recipient.from(context, Address.fromSerialized(senderHexEncodedPublicKey), false) if (message.profilePicture != null && message.profilePicture!!.url.isNotEmpty()) { val profileKey = message.profilePicture!!.profileKey val url = message.profilePicture!!.url if (senderAsRecipient.profileKey == null || !MessageDigest.isEqual(senderAsRecipient.profileKey, profileKey)) { val database = DatabaseFactory.getRecipientDatabase(context) database.setProfileKey(senderAsRecipient, profileKey) ApplicationContext.getInstance(context).jobManager.add(RetrieveProfileAvatarJob(senderAsRecipient, url)) } } } fun processOutgoingMessage(message: PublicChatMessage) { val messageServerID = message.serverID ?: return val isDuplicate = DatabaseFactory.getLokiMessageDatabase(context).getMessageID(messageServerID) != null if (isDuplicate) { return } if (message.body.isEmpty() && message.attachments.isEmpty() && message.quote == null) { return } val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context) val dataMessage = getDataMessage(message) SessionMetaProtocol.dropFromTimestampCacheIfNeeded(dataMessage.timestamp) val transcript = SentTranscriptMessage(userHexEncodedPublicKey, message.serverTimestamp, dataMessage, dataMessage.expiresInSeconds.toLong(), Collections.singletonMap(userHexEncodedPublicKey, false)) transcript.messageServerID = messageServerID if (dataMessage.quote.isPresent || (dataMessage.attachments.isPresent && dataMessage.attachments.get().size > 0) || dataMessage.previews.isPresent) { PushDecryptJob(context).handleSynchronizeSentMediaMessage(transcript) } else { PushDecryptJob(context).handleSynchronizeSentTextMessage(transcript) } // If we got a message from our master device then make sure our mapping stays in sync val recipient = Recipient.from(context, Address.fromSerialized(message.senderPublicKey), false) if (recipient.isUserMasterDevice && message.profilePicture != null) { val profileKey = message.profilePicture!!.profileKey val url = message.profilePicture!!.url if (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, profileKey)) { val database = DatabaseFactory.getRecipientDatabase(context) database.setProfileKey(recipient, profileKey) database.setProfileAvatar(recipient, url) ApplicationContext.getInstance(context).updateOpenGroupProfilePicturesIfNeeded() } } } val userDevices = MultiDeviceProtocol.shared.getAllLinkedDevices(userHexEncodedPublicKey) var uniqueDevices = setOf() val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize() val apiDB = DatabaseFactory.getLokiAPIDatabase(context) FileServerAPI.configure(userHexEncodedPublicKey, userPrivateKey, apiDB) // Kovenant propagates a context to chained promises, so LokiPublicChatAPI.sharedContext should be used for all of the below api.getMessages(group.channel, group.server).bind(PublicChatAPI.sharedContext) { messages -> /* if (messages.isNotEmpty()) { // We need to fetch the device mapping for any devices we don't have uniqueDevices = messages.map { it.senderPublicKey }.toSet() val devicesToUpdate = uniqueDevices.filter { !userDevices.contains(it) && FileServerAPI.shared.hasDeviceLinkCacheExpired(publicKey = it) } if (devicesToUpdate.isNotEmpty()) { return@bind FileServerAPI.shared.getDeviceLinks(devicesToUpdate.toSet()).then { messages } } } */ Promise.of(messages) }.successBackground { /* val newDisplayNameUpdatees = uniqueDevices.mapNotNull { // This will return null if the current device is a master device MultiDeviceProtocol.shared.getMasterDevice(it) }.toSet() // Fetch the display names of the master devices displayNameUpdatees = displayNameUpdatees.union(newDisplayNameUpdatees) */ }.successBackground { messages -> // Process messages in the background messages.forEach { message -> if (userDevices.contains(message.senderPublicKey)) { processOutgoingMessage(message) } else { processIncomingMessage(message) } } isCaughtUp = true }.fail { Log.d("Loki", "Failed to get messages for group chat with ID: ${group.channel} on server: ${group.server}.") } } private fun pollForDisplayNames() { if (displayNameUpdatees.isEmpty()) { return } val hexEncodedPublicKeys = displayNameUpdatees displayNameUpdatees = setOf() api.getDisplayNames(hexEncodedPublicKeys, group.server).successBackground { mapping -> for (pair in mapping.entries) { val senderDisplayName = "${pair.value} (...${pair.key.takeLast(8)})" DatabaseFactory.getLokiUserDatabase(context).setServerDisplayName(group.id, pair.key, senderDisplayName) } }.fail { displayNameUpdatees = displayNameUpdatees.union(hexEncodedPublicKeys) } } private fun pollForDeletedMessages() { api.getDeletedMessageServerIDs(group.channel, group.server).success { deletedMessageServerIDs -> val lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context) val deletedMessageIDs = deletedMessageServerIDs.mapNotNull { lokiMessageDatabase.getMessageID(it) } val smsMessageDatabase = DatabaseFactory.getSmsDatabase(context) val mmsMessageDatabase = DatabaseFactory.getMmsDatabase(context) deletedMessageIDs.forEach { smsMessageDatabase.deleteMessage(it) mmsMessageDatabase.delete(it) } }.fail { Log.d("Loki", "Failed to get deleted messages for group chat with ID: ${group.channel} on server: ${group.server}.") } } private fun pollForModerators() { api.getModerators(group.channel, group.server) } // endregion }