diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index c07dca4f7..ec89c1028 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -21,9 +21,9 @@ import android.content.Intent; import android.os.AsyncTask; import android.os.Build; import android.os.Handler; +import android.os.Looper; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner; @@ -33,6 +33,7 @@ import org.conscrypt.Conscrypt; import org.session.libsession.messaging.MessagingConfiguration; import org.session.libsession.messaging.avatars.AvatarHelper; import org.session.libsession.messaging.jobs.JobQueue; +import org.session.libsession.messaging.opengroups.OpenGroupAPI; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller; import org.session.libsession.messaging.sending_receiving.pollers.Poller; @@ -50,14 +51,12 @@ import org.session.libsignal.service.loki.api.PushNotificationAPI; import org.session.libsignal.service.loki.api.SnodeAPI; import org.session.libsignal.service.loki.api.SwarmAPI; import org.session.libsignal.service.loki.api.fileserver.FileServerAPI; -import org.session.libsignal.service.loki.api.opengroups.PublicChatAPI; import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol; import org.session.libsignal.service.loki.utilities.mentions.MentionsManager; import org.session.libsignal.utilities.logging.Log; import org.signal.aesgcmprovider.AesGcmProvider; import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.dependencies.InjectableType; import org.thoughtcrime.securesms.dependencies.SignalCommunicationModule; import org.thoughtcrime.securesms.jobmanager.DependencyInjector; @@ -140,10 +139,10 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc public Poller poller = null; public ClosedGroupPoller closedGroupPoller = null; public PublicChatManager publicChatManager = null; - private PublicChatAPI publicChatAPI = null; public Broadcaster broadcaster = null; public SignalCommunicationModule communicationModule; private Job firebaseInstanceIdJob; + private Handler threadNotificationHandler; private volatile boolean isAppVisible; @@ -151,7 +150,11 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc return (ApplicationContext) context.getApplicationContext(); } - @Override + public Handler getThreadNotificationHandler() { + return this.threadNotificationHandler; + } + +@Override public void onCreate() { super.onCreate(); Log.i(TAG, "onCreate()"); @@ -166,6 +169,7 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc // ======== messageNotifier = new OptimizedMessageNotifier(new DefaultMessageNotifier()); broadcaster = new Broadcaster(this); + threadNotificationHandler = new Handler(Looper.getMainLooper()); LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this); LokiThreadDatabase threadDB = DatabaseFactory.getLokiThreadDatabase(this); LokiUserDatabase userDB = DatabaseFactory.getLokiUserDatabase(this); @@ -285,22 +289,6 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc } // Loki - public @Nullable - PublicChatAPI getPublicChatAPI() { - if (publicChatAPI != null || !IdentityKeyUtil.hasIdentityKey(this)) { - return publicChatAPI; - } - String userPublicKey = TextSecurePreferences.getLocalNumber(this); - if (userPublicKey == null) { - return publicChatAPI; - } - byte[] userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(this).getPrivateKey().serialize(); - LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this); - LokiUserDatabase userDB = DatabaseFactory.getLokiUserDatabase(this); - GroupDatabase groupDB = DatabaseFactory.getGroupDatabase(this); - publicChatAPI = new PublicChatAPI(userPublicKey, userPrivateKey, apiDB, userDB, groupDB); - return publicChatAPI; - } private void initializeSecurityProvider() { try { @@ -531,21 +519,12 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc public void updateOpenGroupProfilePicturesIfNeeded() { AsyncTask.execute(() -> { - PublicChatAPI publicChatAPI = null; - try { - publicChatAPI = getPublicChatAPI(); - } catch (Exception e) { - // Do nothing - } - if (publicChatAPI == null) { - return; - } byte[] profileKey = ProfileKeyUtil.getProfileKey(this); String url = TextSecurePreferences.getProfilePictureURL(this); Set servers = DatabaseFactory.getLokiThreadDatabase(this).getAllPublicChatServers(); for (String server : servers) { if (profileKey != null) { - publicChatAPI.setProfilePicture(server, profileKey, url); + OpenGroupAPI.setProfilePicture(server, profileKey, url); } } }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java index 4a1161fdd..cab13138a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -16,11 +16,15 @@ */ package org.thoughtcrime.securesms.database; +import android.annotation.SuppressLint; import android.content.Context; import android.database.ContentObserver; import android.database.Cursor; + import androidx.annotation.NonNull; +import org.session.libsession.utilities.Debouncer; +import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import java.util.Set; @@ -31,10 +35,13 @@ public abstract class Database { protected SQLCipherOpenHelper databaseHelper; protected final Context context; + private final Debouncer threadNotificationDebouncer; + @SuppressLint("WrongConstant") public Database(Context context, SQLCipherOpenHelper databaseHelper) { this.context = context; this.databaseHelper = databaseHelper; + this.threadNotificationDebouncer = new Debouncer(ApplicationContext.getInstance(context).getThreadNotificationHandler(), 1000); } protected void notifyConversationListeners(Set threadIds) { @@ -47,7 +54,7 @@ public abstract class Database { } protected void notifyConversationListListeners() { - context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null); + threadNotificationDebouncer.publish(()->context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null)); } protected void notifyStickerListeners() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt index 64116414b..8ed97bf3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt @@ -26,11 +26,12 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import org.session.libsession.messaging.sending_receiving.MessageSender import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.opengroups.OpenGroupAPI +import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.utilities.* import org.session.libsignal.service.loki.utilities.mentions.MentionsManager import org.session.libsignal.service.loki.utilities.toHexString @@ -359,8 +360,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), apiDB.removeLastDeletionServerID(publicChat.channel, publicChat.server) apiDB.clearOpenGroupProfilePictureURL(publicChat.channel, publicChat.server) - ApplicationContext.getInstance(context).publicChatAPI!! - .leave(publicChat.channel, publicChat.server) + OpenGroupAPI.leave(publicChat.channel, publicChat.server) ApplicationContext.getInstance(context).publicChatManager .removeChat(publicChat.server, publicChat.channel) diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/SettingsActivity.kt index cacc35a97..6bd6bb85a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/SettingsActivity.kt @@ -28,6 +28,7 @@ import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.task import nl.komponents.kovenant.ui.alwaysUi import org.session.libsession.messaging.avatars.AvatarHelper +import org.session.libsession.messaging.opengroups.OpenGroupAPI import org.session.libsession.messaging.threads.Address import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.TextSecurePreferences @@ -179,11 +180,8 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val promises = mutableListOf>() val displayName = displayNameToBeUploaded if (displayName != null) { - val publicChatAPI = ApplicationContext.getInstance(this).publicChatAPI - if (publicChatAPI != null) { - val servers = DatabaseFactory.getLokiThreadDatabase(this).getAllPublicChatServers() - promises.addAll(servers.map { publicChatAPI.setDisplayName(displayName, it) }) - } + val servers = DatabaseFactory.getLokiThreadDatabase(this).getAllPublicChatServers() + promises.addAll(servers.map { OpenGroupAPI.setDisplayName(displayName, it) }) TextSecurePreferences.setProfileName(this, displayName) } val profilePicture = profilePictureToBeUploaded diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt index c069cdfe2..c2200ac0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt @@ -7,12 +7,12 @@ import android.text.TextUtils import androidx.annotation.WorkerThread import org.session.libsession.messaging.MessagingConfiguration import org.session.libsession.messaging.opengroups.OpenGroup +import org.session.libsession.messaging.opengroups.OpenGroupAPI +import org.session.libsession.messaging.opengroups.OpenGroupInfo import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.Util import org.session.libsignal.service.loki.api.opengroups.PublicChat -import org.session.libsignal.service.loki.api.opengroups.PublicChatInfo -import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.groups.GroupManager @@ -65,27 +65,23 @@ class PublicChatManager(private val context: Context) { //TODO Declare a specific type of checked exception instead of "Exception". @WorkerThread @Throws(java.lang.Exception::class) - public fun addChat(server: String, channel: Long): PublicChat { - val groupChatAPI = ApplicationContext.getInstance(context).publicChatAPI - ?: throw IllegalStateException("LokiPublicChatAPI is not set!") - + public fun addChat(server: String, channel: Long): OpenGroup { // Ensure the auth token is acquired. - groupChatAPI.getAuthToken(server).get() + OpenGroupAPI.getAuthToken(server).get() - val channelInfo = groupChatAPI.getChannelInfo(channel, server).get() + val channelInfo = OpenGroupAPI.getChannelInfo(channel, server).get() return addChat(server, channel, channelInfo) } @WorkerThread - public fun addChat(server: String, channel: Long, info: PublicChatInfo): PublicChat { + public fun addChat(server: String, channel: Long, info: OpenGroupInfo): OpenGroup { val chat = PublicChat(channel, server, info.displayName, true) var threadID = GroupManager.getOpenGroupThreadID(chat.id, context) var profilePicture: Bitmap? = null // Create the group if we don't have one if (threadID < 0) { if (info.profilePictureURL.isNotEmpty()) { - val profilePictureAsByteArray = ApplicationContext.getInstance(context).publicChatAPI - ?.downloadOpenGroupProfilePicture(server, info.profilePictureURL) + val profilePictureAsByteArray = OpenGroupAPI.downloadOpenGroupProfilePicture(server, info.profilePictureURL) profilePicture = BitmapUtil.fromByteArray(profilePictureAsByteArray) } val result = GroupManager.createOpenGroup(chat.id, context, profilePicture, chat.displayName) @@ -95,12 +91,12 @@ class PublicChatManager(private val context: Context) { // Set our name on the server val displayName = TextSecurePreferences.getProfileName(context) if (!TextUtils.isEmpty(displayName)) { - ApplicationContext.getInstance(context).publicChatAPI?.setDisplayName(displayName, server) + OpenGroupAPI.setDisplayName(displayName, server) } // Start polling Util.runOnMain { startPollersIfNeeded() } - return chat + return OpenGroup.from(chat) } public fun removeChat(server: String, channel: Long) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatPoller.kt deleted file mode 100644 index 87c0e2957..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatPoller.kt +++ /dev/null @@ -1,238 +0,0 @@ -package org.thoughtcrime.securesms.loki.api - -import android.content.Context -import android.os.Handler -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.bind -import nl.komponents.kovenant.functional.map -import org.session.libsession.messaging.threads.Address -import org.session.libsession.messaging.threads.recipients.Recipient -import org.session.libsession.utilities.IdentityKeyUtil -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.libsignal.util.guava.Optional -import org.session.libsignal.service.api.messages.SignalServiceAttachmentPointer -import org.session.libsignal.service.api.messages.SignalServiceContent -import org.session.libsignal.service.api.messages.SignalServiceDataMessage -import org.session.libsignal.service.api.messages.SignalServiceGroup -import org.session.libsignal.service.api.push.SignalServiceAddress -import org.session.libsignal.service.loki.api.fileserver.FileServerAPI -import org.session.libsignal.service.loki.api.opengroups.PublicChat -import org.session.libsignal.service.loki.api.opengroups.PublicChatAPI -import org.session.libsignal.service.loki.api.opengroups.PublicChatMessage -import org.session.libsignal.utilities.logging.Log -import org.session.libsignal.utilities.successBackground -import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.database.DatabaseFactory -import org.thoughtcrime.securesms.jobs.PushDecryptJob -import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob -import java.security.MessageDigest -import java.util.* - -class PublicChatPoller(private val context: Context, private val group: PublicChat) { - private val handler by lazy { Handler() } - private var hasStarted = false - private var isPollOngoing = 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) - val openGroupDatabase = DatabaseFactory.getGroupDatabase(context) - PublicChatAPI(userHexEncodedPublicKey, userPrivateKey, lokiAPIDatabase, lokiUserDatabase, openGroupDatabase) - }() - // 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 - val syncTarget = if (message.senderPublicKey == userHexEncodedPublicKey) group.id else null - return SignalServiceDataMessage(message.timestamp, serviceGroup, attachments, body, 0, false, null, quote, null, signalLinkPreviews, null, syncTarget) - } - - fun pollForNewMessages(): Promise { - if (isPollOngoing) { return Promise.of(Unit) } - isPollOngoing = true - 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 - val promise = api.getMessages(group.channel, group.server).bind(PublicChatAPI.sharedContext) { messages -> - Promise.of(messages) - } - promise.successBackground { messages -> - // Process messages in the background - messages.forEach { message -> - // If the sender of the current message is not a slave device, set the display name in the database - val senderDisplayName = "${message.displayName} (...${message.senderPublicKey.takeLast(8)})" - DatabaseFactory.getLokiUserDatabase(context).setServerDisplayName(group.id, message.senderPublicKey, senderDisplayName) - val senderHexEncodedPublicKey = message.senderPublicKey - val serviceDataMessage = getDataMessage(message) - val serviceContent = SignalServiceContent(serviceDataMessage, senderHexEncodedPublicKey, SignalServiceAddress.DEFAULT_DEVICE_ID, message.serverTimestamp, 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)) - } - } - } - isCaughtUp = true - isPollOngoing = false - } - promise.fail { - Log.d("Loki", "Failed to get messages for group chat with ID: ${group.channel} on server: ${group.server}.") - isPollOngoing = false - } - return promise.map { Unit } - } - - 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 -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/OpenGroupUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/OpenGroupUtilities.kt index 45c792931..c6b789f99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/OpenGroupUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/OpenGroupUtilities.kt @@ -3,16 +3,15 @@ package org.thoughtcrime.securesms.loki.utilities import android.content.Context import androidx.annotation.WorkerThread import org.greenrobot.eventbus.EventBus -import org.thoughtcrime.securesms.ApplicationContext -import org.session.libsession.utilities.preferences.ProfileKeyUtil -import org.thoughtcrime.securesms.database.DatabaseFactory -import org.thoughtcrime.securesms.groups.GroupManager +import org.session.libsession.messaging.opengroups.OpenGroup +import org.session.libsession.messaging.opengroups.OpenGroupAPI import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.preferences.ProfileKeyUtil import org.session.libsignal.service.loki.api.opengroups.PublicChat -import java.lang.Exception -import java.lang.IllegalStateException -import kotlin.jvm.Throws +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.groups.GroupManager //TODO Refactor so methods declare specific type of checked exceptions and not generalized Exception. object OpenGroupUtilities { @@ -22,29 +21,27 @@ object OpenGroupUtilities { @JvmStatic @WorkerThread @Throws(Exception::class) - fun addGroup(context: Context, url: String, channel: Long): PublicChat { + fun addGroup(context: Context, url: String, channel: Long): OpenGroup { // Check for an existing group. val groupID = PublicChat.getId(channel, url) val threadID = GroupManager.getOpenGroupThreadID(groupID, context) val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID) - if (openGroup != null) { return openGroup } + if (openGroup != null) { return OpenGroup.from(openGroup) } // Add the new group. val application = ApplicationContext.getInstance(context) val displayName = TextSecurePreferences.getProfileName(context) - val lokiPublicChatAPI = application.publicChatAPI - ?: throw IllegalStateException("LokiPublicChatAPI is not initialized.") val group = application.publicChatManager.addChat(url, channel) DatabaseFactory.getLokiAPIDatabase(context).removeLastMessageServerID(channel, url) DatabaseFactory.getLokiAPIDatabase(context).removeLastDeletionServerID(channel, url) - lokiPublicChatAPI.getMessages(channel, url) - lokiPublicChatAPI.setDisplayName(displayName, url) - lokiPublicChatAPI.join(channel, url) + OpenGroupAPI.getMessages(channel, url) + OpenGroupAPI.setDisplayName(displayName, url) + OpenGroupAPI.join(channel, url) val profileKey: ByteArray = ProfileKeyUtil.getProfileKey(context) val profileUrl: String? = TextSecurePreferences.getProfilePictureURL(context) - lokiPublicChatAPI.setProfilePicture(url, profileKey, profileUrl) + OpenGroupAPI.setProfilePicture(url, profileKey, profileUrl) return group } @@ -58,18 +55,15 @@ object OpenGroupUtilities { @WorkerThread @Throws(Exception::class) fun updateGroupInfo(context: Context, url: String, channel: Long) { - val publicChatAPI = ApplicationContext.getInstance(context).publicChatAPI - ?: throw IllegalStateException("Public chat API is not initialized!") - // Check if open group has a related DB record. val groupId = GroupUtil.getEncodedOpenGroupID(PublicChat.getId(channel, url).toByteArray()) if (!DatabaseFactory.getGroupDatabase(context).hasGroup(groupId)) { throw IllegalStateException("Attempt to update open group info for non-existent DB record: $groupId") } - val info = publicChatAPI.getChannelInfo(channel, url).get() + val info = OpenGroupAPI.getChannelInfo(channel, url).get() - publicChatAPI.updateProfileIfNeeded(channel, url, groupId, info, false) + OpenGroupAPI.updateProfileIfNeeded(channel, url, groupId, info, false) EventBus.getDefault().post(GroupInfoUpdatedEvent(url, channel)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt index d33ff9f34..f85e5e986 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt @@ -8,9 +8,9 @@ import android.view.ViewGroup import android.widget.LinearLayout import kotlinx.android.synthetic.main.view_mention_candidate.view.* import network.loki.messenger.R -import org.thoughtcrime.securesms.mms.GlideRequests -import org.session.libsignal.service.loki.api.opengroups.PublicChatAPI +import org.session.libsession.messaging.opengroups.OpenGroupAPI import org.session.libsignal.service.loki.utilities.mentions.Mention +import org.thoughtcrime.securesms.mms.GlideRequests class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : LinearLayout(context, attrs, defStyleAttr) { var mentionCandidate = Mention("", "") @@ -38,7 +38,7 @@ class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: profilePictureView.glide = glide!! profilePictureView.update() if (publicChatServer != null && publicChatChannel != null) { - val isUserModerator = PublicChatAPI.isUserModerator(mentionCandidate.publicKey, publicChatChannel!!, publicChatServer!!) + val isUserModerator = OpenGroupAPI.isUserModerator(mentionCandidate.publicKey, publicChatChannel!!, publicChatServer!!) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE } else { moderatorIconImageView.visibility = View.GONE diff --git a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt index 81ea7abcb..c0a48274c 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroup.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.opengroups +import org.session.libsignal.service.loki.api.opengroups.PublicChat import org.session.libsignal.utilities.JsonUtil data class OpenGroup( @@ -13,6 +14,9 @@ data class OpenGroup( companion object { + @JvmStatic fun from(publicChat: PublicChat): OpenGroup = + OpenGroup(publicChat.channel, publicChat.server, publicChat.displayName, publicChat.isDeletable) + @JvmStatic fun getId(channel: Long, server: String): String { return "$server.$channel" } diff --git a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt index c8c53dc6b..6c35888f7 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupAPI.kt @@ -6,15 +6,13 @@ import nl.komponents.kovenant.deferred import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.then import org.session.libsession.messaging.MessagingConfiguration - -import org.session.libsession.messaging.utilities.DotNetAPI import org.session.libsession.messaging.fileserver.FileServerAPI - -import org.session.libsignal.utilities.logging.Log -import org.session.libsignal.utilities.* +import org.session.libsession.messaging.utilities.DotNetAPI import org.session.libsignal.service.loki.utilities.DownloadUtilities import org.session.libsignal.service.loki.utilities.retryIfNeeded +import org.session.libsignal.utilities.* import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.logging.Log import java.io.ByteArrayOutputStream import java.text.SimpleDateFormat import java.util.* @@ -156,6 +154,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun getDeletedMessageServerIDs(channel: Long, server: String): Promise, Exception> { Log.d("Loki", "Getting deleted messages for open group with ID: $channel on server: $server.") val storage = MessagingConfiguration.shared.storage @@ -188,6 +187,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun sendMessage(message: OpenGroupMessage, channel: Long, server: String): Promise { val deferred = deferred() val storage = MessagingConfiguration.shared.storage @@ -252,6 +252,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun getModerators(channel: Long, server: String): Promise, Exception> { return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/get_moderators").then(sharedContext) { json -> try { @@ -270,6 +271,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun getChannelInfo(channel: Long, server: String): Promise { return retryIfNeeded(maxRetryCount) { val parameters = mapOf( "include_annotations" to 1 ) @@ -294,6 +296,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun updateProfileIfNeeded(channel: Long, server: String, groupID: String, info: OpenGroupInfo, isForcedUpdate: Boolean) { val storage = MessagingConfiguration.shared.storage storage.setUserCount(channel, server, info.memberCount) @@ -307,6 +310,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun downloadOpenGroupProfilePicture(server: String, endpoint: String): ByteArray? { val url = "${server.removeSuffix("/")}/${endpoint.removePrefix("/")}" Log.d("Loki", "Downloading open group profile picture from \"$url\".") @@ -323,6 +327,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun join(channel: Long, server: String): Promise { return retryIfNeeded(maxRetryCount) { execute(HTTPVerb.POST, server, "/channels/$channel/subscribe").then { @@ -331,6 +336,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun leave(channel: Long, server: String): Promise { return retryIfNeeded(maxRetryCount) { execute(HTTPVerb.DELETE, server, "/channels/$channel/subscribe").then { @@ -348,6 +354,7 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun getDisplayNames(publicKeys: Set, server: String): Promise, Exception> { return getUserProfiles(publicKeys, server, false).map(sharedContext) { json -> val mapping = mutableMapOf() @@ -362,12 +369,14 @@ object OpenGroupAPI: DotNetAPI() { } } + @JvmStatic fun setDisplayName(newDisplayName: String?, server: String): Promise { Log.d("Loki", "Updating display name on server: $server.") val parameters = mapOf( "name" to (newDisplayName ?: "") ) return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters).map { Unit } } + @JvmStatic fun setProfilePicture(server: String, profileKey: ByteArray, url: String?): Promise { return setProfilePicture(server, Base64.encodeBytes(profileKey), url) } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index e1b7e652e..216d79627 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -101,7 +101,6 @@ object MessageReceiver { } // Don't process the envelope any further if the message has been handled already if (storage.isMessageDuplicated(envelope.timestamp, sender!!) && !isRetry) throw Error.DuplicateMessage - storage.addReceivedMessageTimestamp(envelope.timestamp) // Don't process the envelope any further if the sender is blocked if (isBlock(sender!!)) throw Error.SenderBlocked // Parse the proto diff --git a/libsession/src/main/java/org/session/libsession/utilities/Debouncer.java b/libsession/src/main/java/org/session/libsession/utilities/Debouncer.java index 0bf82e3e0..84312a4c2 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Debouncer.java +++ b/libsession/src/main/java/org/session/libsession/utilities/Debouncer.java @@ -25,6 +25,11 @@ public class Debouncer { this.threshold = threshold; } + public Debouncer(Handler handler, long threshold) { + this.handler = handler; + this.threshold = threshold; + } + public void publish(Runnable runnable) { handler.removeCallbacksAndMessages(null); handler.postDelayed(runnable, threshold); diff --git a/libsignal/src/main/java/org/session/libsignal/service/loki/api/opengroups/PublicChatAPI.kt b/libsignal/src/main/java/org/session/libsignal/service/loki/api/opengroups/PublicChatAPI.kt deleted file mode 100644 index 3a4c58db0..000000000 --- a/libsignal/src/main/java/org/session/libsignal/service/loki/api/opengroups/PublicChatAPI.kt +++ /dev/null @@ -1,386 +0,0 @@ -package org.session.libsignal.service.loki.api.opengroups - -import nl.komponents.kovenant.Kovenant -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred -import nl.komponents.kovenant.functional.map -import nl.komponents.kovenant.then -import org.session.libsignal.utilities.logging.Log -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.service.loki.api.LokiDotNetAPI -import org.session.libsignal.service.loki.api.SnodeAPI -import org.session.libsignal.service.loki.api.fileserver.FileServerAPI -import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol -import org.session.libsignal.service.loki.database.LokiOpenGroupDatabaseProtocol -import org.session.libsignal.service.loki.database.LokiUserDatabaseProtocol -import org.session.libsignal.service.loki.utilities.DownloadUtilities -import org.session.libsignal.utilities.* -import org.session.libsignal.service.loki.utilities.retryIfNeeded -import java.io.ByteArrayOutputStream -import java.text.SimpleDateFormat -import java.util.* - -class PublicChatAPI(userPublicKey: String, private val userPrivateKey: ByteArray, private val apiDatabase: LokiAPIDatabaseProtocol, - private val userDatabase: LokiUserDatabaseProtocol, private val openGroupDatabase: LokiOpenGroupDatabaseProtocol) : LokiDotNetAPI(userPublicKey, userPrivateKey, apiDatabase) { - - companion object { - private val moderators: HashMap>> = hashMapOf() // Server URL to (channel ID to set of moderator IDs) - val sharedContext = Kovenant.createContext() - - // region Settings - private val fallbackBatchCount = 64 - private val maxRetryCount = 8 - // endregion - - // region Convenience - private val channelInfoType = "net.patter-app.settings" - private val attachmentType = "net.app.core.oembed" - @JvmStatic - val publicChatMessageType = "network.loki.messenger.publicChat" - @JvmStatic - val profilePictureType = "network.loki.messenger.avatar" - - fun getDefaultChats(): List { - return listOf() // Don't auto-join any open groups right now - } - - fun isUserModerator(hexEncodedPublicKey: String, channel: Long, server: String): Boolean { - if (moderators[server] != null && moderators[server]!![channel] != null) { - return moderators[server]!![channel]!!.contains(hexEncodedPublicKey) - } - return false - } - // endregion - } - - // region Public API - fun getMessages(channel: Long, server: String): Promise, Exception> { - Log.d("Loki", "Getting messages for open group with ID: $channel on server: $server.") - val parameters = mutableMapOf( "include_annotations" to 1 ) - val lastMessageServerID = apiDatabase.getLastMessageServerID(channel, server) - if (lastMessageServerID != null) { - parameters["since_id"] = lastMessageServerID - } else { - parameters["count"] = fallbackBatchCount - parameters["include_deleted"] = 0 - } - return execute(HTTPVerb.GET, server, "channels/$channel/messages", parameters = parameters).then(sharedContext) { json -> - try { - val data = json["data"] as List> - val messages = data.mapNotNull { message -> - try { - val isDeleted = message["is_deleted"] as? Boolean ?: false - if (isDeleted) { return@mapNotNull null } - // Ignore messages without annotations - if (message["annotations"] == null) { return@mapNotNull null } - val annotation = (message["annotations"] as List>).find { - ((it["type"] as? String ?: "") == publicChatMessageType) && it["value"] != null - } ?: return@mapNotNull null - val value = annotation["value"] as Map<*, *> - val serverID = message["id"] as? Long ?: (message["id"] as? Int)?.toLong() ?: (message["id"] as String).toLong() - val user = message["user"] as Map<*, *> - val publicKey = user["username"] as String - val displayName = user["name"] as? String ?: "Anonymous" - var profilePicture: PublicChatMessage.ProfilePicture? = null - if (user["annotations"] != null) { - val profilePictureAnnotation = (user["annotations"] as List>).find { - ((it["type"] as? String ?: "") == profilePictureType) && it["value"] != null - } - val profilePictureAnnotationValue = profilePictureAnnotation?.get("value") as? Map<*, *> - if (profilePictureAnnotationValue != null && profilePictureAnnotationValue["profileKey"] != null && profilePictureAnnotationValue["url"] != null) { - try { - val profileKey = Base64.decode(profilePictureAnnotationValue["profileKey"] as String) - val url = profilePictureAnnotationValue["url"] as String - profilePicture = PublicChatMessage.ProfilePicture(profileKey, url) - } catch (e: Exception) {} - } - } - @Suppress("NAME_SHADOWING") val body = message["text"] as String - val timestamp = value["timestamp"] as? Long ?: (value["timestamp"] as? Int)?.toLong() ?: (value["timestamp"] as String).toLong() - var quote: PublicChatMessage.Quote? = null - if (value["quote"] != null) { - val replyTo = message["reply_to"] as? Long ?: (message["reply_to"] as? Int)?.toLong() ?: (message["reply_to"] as String).toLong() - val quoteAnnotation = value["quote"] as? Map<*, *> - val quoteTimestamp = quoteAnnotation?.get("id") as? Long ?: (quoteAnnotation?.get("id") as? Int)?.toLong() ?: (quoteAnnotation?.get("id") as? String)?.toLong() ?: 0L - val author = quoteAnnotation?.get("author") as? String - val text = quoteAnnotation?.get("text") as? String - quote = if (quoteTimestamp > 0L && author != null && text != null) PublicChatMessage.Quote(quoteTimestamp, author, text, replyTo) else null - } - val attachmentsAsJSON = (message["annotations"] as List>).filter { - ((it["type"] as? String ?: "") == attachmentType) && it["value"] != null - } - val attachments = attachmentsAsJSON.mapNotNull { it["value"] as? Map<*, *> }.mapNotNull { attachmentAsJSON -> - try { - val kindAsString = attachmentAsJSON["lokiType"] as String - val kind = PublicChatMessage.Attachment.Kind.values().first { it.rawValue == kindAsString } - val id = attachmentAsJSON["id"] as? Long ?: (attachmentAsJSON["id"] as? Int)?.toLong() ?: (attachmentAsJSON["id"] as String).toLong() - val contentType = attachmentAsJSON["contentType"] as String - val size = attachmentAsJSON["size"] as? Int ?: (attachmentAsJSON["size"] as? Long)?.toInt() ?: (attachmentAsJSON["size"] as String).toInt() - val fileName = attachmentAsJSON["fileName"] as String - val flags = 0 - val url = attachmentAsJSON["url"] as String - val caption = attachmentAsJSON["caption"] as? String - val linkPreviewURL = attachmentAsJSON["linkPreviewUrl"] as? String - val linkPreviewTitle = attachmentAsJSON["linkPreviewTitle"] as? String - if (kind == PublicChatMessage.Attachment.Kind.LinkPreview && (linkPreviewURL == null || linkPreviewTitle == null)) { - null - } else { - PublicChatMessage.Attachment(kind, server, id, contentType, size, fileName, flags, 0, 0, caption, url, linkPreviewURL, linkPreviewTitle) - } - } catch (e: Exception) { - Log.d("Loki","Couldn't parse attachment due to error: $e.") - null - } - } - // Set the last message server ID here to avoid the situation where a message doesn't have a valid signature and this function is called over and over - @Suppress("NAME_SHADOWING") val lastMessageServerID = apiDatabase.getLastMessageServerID(channel, server) - if (serverID > lastMessageServerID ?: 0) { apiDatabase.setLastMessageServerID(channel, server, serverID) } - val hexEncodedSignature = value["sig"] as String - val signatureVersion = value["sigver"] as? Long ?: (value["sigver"] as? Int)?.toLong() ?: (value["sigver"] as String).toLong() - val signature = PublicChatMessage.Signature(Hex.fromStringCondensed(hexEncodedSignature), signatureVersion) - val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - format.timeZone = TimeZone.getTimeZone("GMT") - val dateAsString = message["created_at"] as String - val serverTimestamp = format.parse(dateAsString).time - // Verify the message - val groupMessage = PublicChatMessage(serverID, publicKey, displayName, body, timestamp, publicChatMessageType, quote, attachments, profilePicture, signature, serverTimestamp) - if (groupMessage.hasValidSignature()) groupMessage else null - } catch (exception: Exception) { - Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server from: ${JsonUtil.toJson(message)}. Exception: ${exception.message}") - return@mapNotNull null - } - }.sortedBy { it.serverTimestamp } - messages - } catch (exception: Exception) { - Log.d("Loki", "Couldn't parse messages for open group with ID: $channel on server: $server.") - throw exception - } - } - } - - fun getDeletedMessageServerIDs(channel: Long, server: String): Promise, Exception> { - Log.d("Loki", "Getting deleted messages for open group with ID: $channel on server: $server.") - val parameters = mutableMapOf() - val lastDeletionServerID = apiDatabase.getLastDeletionServerID(channel, server) - if (lastDeletionServerID != null) { - parameters["since_id"] = lastDeletionServerID - } else { - parameters["count"] = fallbackBatchCount - } - return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/deletes", parameters = parameters).then(sharedContext) { json -> - try { - val deletedMessageServerIDs = (json["data"] as List>).mapNotNull { deletion -> - try { - val serverID = deletion["id"] as? Long ?: (deletion["id"] as? Int)?.toLong() ?: (deletion["id"] as String).toLong() - val messageServerID = deletion["message_id"] as? Long ?: (deletion["message_id"] as? Int)?.toLong() ?: (deletion["message_id"] as String).toLong() - @Suppress("NAME_SHADOWING") val lastDeletionServerID = apiDatabase.getLastDeletionServerID(channel, server) - if (serverID > (lastDeletionServerID ?: 0)) { apiDatabase.setLastDeletionServerID(channel, server, serverID) } - messageServerID - } catch (exception: Exception) { - Log.d("Loki", "Couldn't parse deleted message for open group with ID: $channel on server: $server. Exception: ${exception.message}") - return@mapNotNull null - } - } - deletedMessageServerIDs - } catch (exception: Exception) { - Log.d("Loki", "Couldn't parse deleted messages for open group with ID: $channel on server: $server.") - throw exception - } - } - } - - fun sendMessage(message: PublicChatMessage, channel: Long, server: String): Promise { - val deferred = deferred() - ThreadUtils.queue { - val signedMessage = message.sign(userPrivateKey) - if (signedMessage == null) { - deferred.reject(SnodeAPI.Error.MessageSigningFailed) - } else { - retryIfNeeded(maxRetryCount) { - Log.d("Loki", "Sending message to open group with ID: $channel on server: $server.") - val parameters = signedMessage.toJSON() - execute(HTTPVerb.POST, server, "channels/$channel/messages", parameters = parameters).then(sharedContext) { json -> - try { - val data = json["data"] as Map<*, *> - val serverID = (data["id"] as? Long) ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as String).toLong() - val displayName = userDatabase.getDisplayName(userPublicKey) ?: "Anonymous" - val text = data["text"] as String - val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - format.timeZone = TimeZone.getTimeZone("GMT") - val dateAsString = data["created_at"] as String - val timestamp = format.parse(dateAsString).time - @Suppress("NAME_SHADOWING") val message = PublicChatMessage(serverID, userPublicKey, displayName, text, timestamp, publicChatMessageType, message.quote, message.attachments, null, signedMessage.signature, timestamp) - message - } catch (exception: Exception) { - Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server.") - throw exception - } - } - }.success { - deferred.resolve(it) - }.fail { - deferred.reject(it) - } - } - } - return deferred.promise - } - - fun deleteMessage(messageServerID: Long, channel: Long, server: String, isSentByUser: Boolean): Promise { - return retryIfNeeded(maxRetryCount) { - val isModerationRequest = !isSentByUser - Log.d("Loki", "Deleting message with ID: $messageServerID from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).") - val endpoint = if (isSentByUser) "channels/$channel/messages/$messageServerID" else "loki/v1/moderation/message/$messageServerID" - execute(HTTPVerb.DELETE, server, endpoint, isJSONRequired = false).then { - Log.d("Loki", "Deleted message with ID: $messageServerID from open group with ID: $channel on server: $server.") - messageServerID - } - } - } - - fun deleteMessages(messageServerIDs: List, channel: Long, server: String, isSentByUser: Boolean): Promise, Exception> { - return retryIfNeeded(maxRetryCount) { - val isModerationRequest = !isSentByUser - val parameters = mapOf( "ids" to messageServerIDs.joinToString(",") ) - Log.d("Loki", "Deleting messages with IDs: ${messageServerIDs.joinToString()} from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).") - val endpoint = if (isSentByUser) "loki/v1/messages" else "loki/v1/moderation/messages" - execute(HTTPVerb.DELETE, server, endpoint, parameters = parameters, isJSONRequired = false).then { json -> - Log.d("Loki", "Deleted messages with IDs: $messageServerIDs from open group with ID: $channel on server: $server.") - messageServerIDs - } - } - } - - fun getModerators(channel: Long, server: String): Promise, Exception> { - return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/get_moderators").then(sharedContext) { json -> - try { - @Suppress("UNCHECKED_CAST") val moderators = json["moderators"] as? List - val moderatorsAsSet = moderators.orEmpty().toSet() - if (Companion.moderators[server] != null) { - Companion.moderators[server]!![channel] = moderatorsAsSet - } else { - Companion.moderators[server] = hashMapOf( channel to moderatorsAsSet ) - } - moderatorsAsSet - } catch (exception: Exception) { - Log.d("Loki", "Couldn't parse moderators for open group with ID: $channel on server: $server.") - throw exception - } - } - } - - fun getChannelInfo(channel: Long, server: String): Promise { - return retryIfNeeded(maxRetryCount) { - val parameters = mapOf( "include_annotations" to 1 ) - execute(HTTPVerb.GET, server, "/channels/$channel", parameters = parameters).then(sharedContext) { json -> - try { - val data = json["data"] as Map<*, *> - val annotations = data["annotations"] as List> - val annotation = annotations.find { (it["type"] as? String ?: "") == channelInfoType } ?: throw SnodeAPI.Error.ParsingFailed - val info = annotation["value"] as Map<*, *> - val displayName = info["name"] as String - val countInfo = data["counts"] as Map<*, *> - val memberCount = countInfo["subscribers"] as? Int ?: (countInfo["subscribers"] as? Long)?.toInt() ?: (countInfo["subscribers"] as String).toInt() - val profilePictureURL = info["avatar"] as String - val publicChatInfo = PublicChatInfo(displayName, profilePictureURL, memberCount) - apiDatabase.setUserCount(channel, server, memberCount) - publicChatInfo - } catch (exception: Exception) { - Log.d("Loki", "Couldn't parse info for open group with ID: $channel on server: $server.") - throw exception - } - } - } - } - - fun updateProfileIfNeeded(channel: Long, server: String, groupID: String, info: PublicChatInfo, isForcedUpdate: Boolean) { - apiDatabase.setUserCount(channel, server, info.memberCount) - openGroupDatabase.updateTitle(groupID, info.displayName) - // Download and update profile picture if needed - val oldProfilePictureURL = apiDatabase.getOpenGroupProfilePictureURL(channel, server) - if (isForcedUpdate || oldProfilePictureURL != info.profilePictureURL) { - val profilePictureAsByteArray = downloadOpenGroupProfilePicture(server, info.profilePictureURL) ?: return - openGroupDatabase.updateProfilePicture(groupID, profilePictureAsByteArray) - apiDatabase.setOpenGroupProfilePictureURL(channel, server, info.profilePictureURL) - } - } - - fun downloadOpenGroupProfilePicture(server: String, endpoint: String): ByteArray? { - val url = "${server.removeSuffix("/")}/${endpoint.removePrefix("/")}" - Log.d("Loki", "Downloading open group profile picture from \"$url\".") - val outputStream = ByteArrayOutputStream() - try { - DownloadUtilities.downloadFile(outputStream, url, FileServerAPI.maxFileSize, null) - Log.d("Loki", "Open group profile picture was successfully loaded from \"$url\"") - return outputStream.toByteArray() - } catch (e: Exception) { - Log.d("Loki", "Failed to download open group profile picture from \"$url\" due to error: $e.") - return null - } finally { - outputStream.close() - } - } - - fun join(channel: Long, server: String): Promise { - return retryIfNeeded(maxRetryCount) { - execute(HTTPVerb.POST, server, "/channels/$channel/subscribe").then { - Log.d("Loki", "Joined channel with ID: $channel on server: $server.") - } - } - } - - fun leave(channel: Long, server: String): Promise { - return retryIfNeeded(maxRetryCount) { - execute(HTTPVerb.DELETE, server, "/channels/$channel/subscribe").then { - Log.d("Loki", "Left channel with ID: $channel on server: $server.") - } - } - } - - fun ban(publicKey: String, server: String): Promise { - return retryIfNeeded(maxRetryCount) { - execute(HTTPVerb.POST, server, "/loki/v1/moderation/blacklist/@$publicKey").then { - Log.d("Loki", "Banned user with ID: $publicKey from $server") - } - } - } - - fun getDisplayNames(publicKeys: Set, server: String): Promise, Exception> { - return getUserProfiles(publicKeys, server, false).map(sharedContext) { json -> - val mapping = mutableMapOf() - for (user in json) { - if (user["username"] != null) { - val publicKey = user["username"] as String - val displayName = user["name"] as? String ?: "Anonymous" - mapping[publicKey] = displayName - } - } - mapping - } - } - - fun setDisplayName(newDisplayName: String?, server: String): Promise { - Log.d("Loki", "Updating display name on server: $server.") - val parameters = mapOf( "name" to (newDisplayName ?: "") ) - return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters).map { Unit } - } - - fun setProfilePicture(server: String, profileKey: ByteArray, url: String?): Promise { - return setProfilePicture(server, Base64.encodeBytes(profileKey), url) - } - - fun setProfilePicture(server: String, profileKey: String, url: String?): Promise { - Log.d("Loki", "Updating profile picture on server: $server.") - val value = when (url) { - null -> null - else -> mapOf( "profileKey" to profileKey, "url" to url ) - } - // TODO: This may actually completely replace the annotations, have to double check it - return setSelfAnnotation(server, profilePictureType, value).map { Unit }.fail { - Log.d("Loki", "Failed to update profile picture due to error: $it.") - } - } - // endregion -}