diff --git a/app/build.gradle b/app/build.gradle index 4c6d0a3f89..deee780d1f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -104,7 +104,8 @@ dependencies { implementation project(":libsession") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "org.whispersystems:curve25519-java:$curve25519Version" - implementation 'com.goterl:lazysodium-android:5.0.2@aar' + implementation project(":liblazysodium") +// implementation 'com.goterl:lazysodium-android:5.0.2@aar' implementation "net.java.dev.jna:jna:5.8.0@aar" implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" diff --git a/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt new file mode 100644 index 0000000000..f9f6c5e089 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt @@ -0,0 +1,28 @@ +package network.loki.messenger + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.goterl.lazysodium.utils.Key +import com.goterl.lazysodium.utils.KeyPair +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.session.libsession.messaging.utilities.SodiumUtilities + +@RunWith(AndroidJUnit4::class) +class SodiumUtilitiesTest { + + private val serverPublicKey = "c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d" + private val pubKey = Key.fromHexString("bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc") + private val secKey = Key.fromHexString("c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9") + + @Test + fun blindedKeyPair() { + val blindedKey = "98932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5" + + val keyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, KeyPair(pubKey, secKey))!! + + assertThat(keyPair.publicKey.asHexString.lowercase(), equalTo(blindedKey)) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index e7c5b7e9e1..2e4b889235 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -57,7 +57,7 @@ import org.session.libsession.messaging.messages.control.DataExtractionNotificat import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview @@ -500,7 +500,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun getLatestOpenGroupInfoIfNeeded() { val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return - OpenGroupAPIV2.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() } + OpenGroupApiV4.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() } } // called from onCreate diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt index 5486cf0e9d..f77dce5a0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt @@ -7,7 +7,7 @@ import android.view.View import android.widget.LinearLayout import network.loki.messenger.databinding.ViewMentionCandidateBinding import org.session.libsession.messaging.mentions.Mention -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.thoughtcrime.securesms.mms.GlideRequests class MentionCandidateView : LinearLayout { @@ -34,7 +34,7 @@ class MentionCandidateView : LinearLayout { profilePictureView.glide = glide!! profilePictureView.update() if (openGroupServer != null && openGroupRoom != null) { - val isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, openGroupRoom!!, openGroupServer!!) + val isUserModerator = OpenGroupApiV4.isUserModerator(mentionCandidate.publicKey, openGroupRoom!!, openGroupServer!!) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE } else { moderatorIconImageView.visibility = View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index 2266a1c562..4c2cc4dec0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -7,7 +7,7 @@ import android.view.View import android.widget.RelativeLayout import network.loki.messenger.databinding.ViewMentionCandidateV2Binding import org.session.libsession.messaging.mentions.Mention -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.thoughtcrime.securesms.mms.GlideRequests class MentionCandidateView : RelativeLayout { @@ -34,7 +34,7 @@ class MentionCandidateView : RelativeLayout { profilePictureView.glide = glide!! profilePictureView.update() if (openGroupServer != null && openGroupRoom != null) { - val isUserModerator = OpenGroupAPIV2.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!) + val isUserModerator = OpenGroupApiV4.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE } else { moderatorIconImageView.visibility = View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 275691c002..a405b90352 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -5,7 +5,7 @@ import android.view.ActionMode import android.view.Menu import android.view.MenuItem import network.loki.messenger.R -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter @@ -41,13 +41,13 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p if (!ConversationActivityV2.IS_UNSEND_REQUESTS_ENABLED) { if (openGroup == null) { return true } if (allSentByCurrentUser) { return true } - return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) + return OpenGroupApiV4.isUserModerator(userPublicKey, openGroup.room, openGroup.server) } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing } if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser } if (allSentByCurrentUser) { return true } - return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) + return OpenGroupApiV4.isUserModerator(userPublicKey, openGroup.room, openGroup.server) } fun userCanBanSelectedUsers(): Boolean { if (openGroup == null) { return false } @@ -55,7 +55,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p if (anySentByCurrentUser) { return false } // Users can't ban themselves val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet() if (selectedUsers.size > 1) { return false } - return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) + return OpenGroupApiV4.isUserModerator(userPublicKey, openGroup.room, openGroup.server) } // Delete message menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 615c5ff094..47ab00282a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -24,7 +24,7 @@ import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageBinding import org.session.libsession.messaging.contacts.Contact.ContactContext -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.utilities.ViewUtil import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.ApplicationContext @@ -127,7 +127,7 @@ class VisibleMessageView : LinearLayout { } if (thread.isOpenGroupRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return - val isModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, openGroup.room, openGroup.server) + val isModerator = OpenGroupApiV4.isUserModerator(senderSessionID, openGroup.room, openGroup.server) binding.moderatorIconImageView.visibility = if (isModerator) View.VISIBLE else View.INVISIBLE } else { binding.moderatorIconImageView.visibility = View.INVISIBLE diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt index a9b6662d8b..b499e772db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt @@ -4,19 +4,19 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.thoughtcrime.securesms.util.State -typealias DefaultGroups = List +typealias DefaultGroups = List typealias GroupState = State class DefaultGroupsViewModel : ViewModel() { init { - OpenGroupAPIV2.getDefaultRoomsIfNeeded() + OpenGroupApiV4.getDefaultRoomsIfNeeded() } - val defaultRooms = OpenGroupAPIV2.defaultRooms.map { + val defaultRooms = OpenGroupApiV4.defaultRooms.map { State.Success(it) }.onStart { emit(State.Loading) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt index ba87e8706d..40f628662e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt @@ -25,7 +25,7 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityJoinPublicChatBinding import network.loki.messenger.databinding.FragmentEnterChatUrlBinding import okhttp3.HttpUrl -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2.DefaultGroup +import org.session.libsession.messaging.open_groups.OpenGroupApiV4.DefaultGroup import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index e18f1a8e8f..5ca8ac364a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -4,18 +4,17 @@ import android.content.Context import androidx.annotation.WorkerThread import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.messaging.open_groups.OpenGroupV2 -import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV4 import org.session.libsession.utilities.Util import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.BitmapUtil import java.util.concurrent.Executors object OpenGroupManager { private val executorService = Executors.newScheduledThreadPool(4) - private var pollers = mutableMapOf() // One for each server + private var pollers = mutableMapOf() // One for each server private var isPolling = false val isAllCaughtUp: Boolean @@ -42,7 +41,7 @@ object OpenGroupManager { val servers = storage.getAllV2OpenGroups().values.map { it.server }.toSet() servers.forEach { server -> pollers[server]?.stop() // Shouldn't be necessary - val poller = OpenGroupPollerV2(server, executorService) + val poller = OpenGroupPollerV4(server, executorService) poller.startIfNeeded() pollers[server] = poller } @@ -68,18 +67,20 @@ object OpenGroupManager { // Store the public key storage.setOpenGroupPublicKey(server,publicKey) // Get an auth token - OpenGroupAPIV2.getAuthToken(room, server).get() + OpenGroupApiV4.getAuthToken(room, server).get() + // Get capabilities + val capabilities = OpenGroupApiV4.getCapabilities(room, server).get() // Get group info - val info = OpenGroupAPIV2.getInfo(room, server).get() + val info = OpenGroupApiV4.getInfo(room, server).get() // Create the group locally if not available already if (threadID < 0) { threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId } - val openGroup = OpenGroupV2(server, room, info.name, publicKey) + val openGroup = OpenGroupV2(server, room, info.name, publicKey, capabilities) threadDB.setOpenGroupChat(openGroup, threadID) // Start the poller if needed pollers[server]?.startIfNeeded() ?: run { - val poller = OpenGroupPollerV2(server, executorService) + val poller = OpenGroupPollerV4(server, executorService) Util.runOnMain { poller.startIfNeeded() } pollers[server] = poller } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt index ea4dc038a7..85f7cf4599 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupUtilities.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.annotation.WorkerThread import org.greenrobot.eventbus.EventBus import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -29,8 +30,8 @@ object OpenGroupUtilities { throw IllegalStateException("Attempt to update open group info for non-existent DB record: $groupId") } - val info = OpenGroupAPIV2.getInfo(room, server).get() // store info again? - OpenGroupAPIV2.getMemberCount(room, server).get() + val info = OpenGroupApiV4.getInfo(room, server).get() // store info again? + OpenGroupApiV4.getMemberCount(room, server).get() EventBus.getDefault().post(GroupInfoUpdatedEvent(server, room = room)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index cac1bfb9a0..9e2404db93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -3,14 +3,20 @@ package org.thoughtcrime.securesms.notifications import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import androidx.work.* +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.map import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 -import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV4 import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log @@ -68,9 +74,9 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor val v2OpenGroupServers = v2OpenGroups.map { it.value.server }.toSet() for (server in v2OpenGroupServers) { - val poller = OpenGroupPollerV2(server, null) + val poller = OpenGroupPollerV4(server, null) poller.hasStarted = true - promises.add(poller.poll(true)) + promises.add(poller.poll()) } // Wait until all the promises are resolved diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 296b4c7875..1ea638624b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -7,7 +7,7 @@ import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -154,7 +154,7 @@ class DefaultConversationRepository @Inject constructor( val openGroup = lokiThreadDb.getOpenGroupChat(threadId) if (openGroup != null) { lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> - OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) + OpenGroupApiV4.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) continuation.resume(ResultOf.Success(Unit)) @@ -206,7 +206,7 @@ class DefaultConversationRepository @Inject constructor( messageServerIDs[messageServerID] = message } for ((messageServerID, message) in messageServerIDs) { - OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) + OpenGroupApiV4.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) }.fail { error -> @@ -229,7 +229,7 @@ class DefaultConversationRepository @Inject constructor( suspendCoroutine { continuation -> val sessionID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server) + OpenGroupApiV4.ban(sessionID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> @@ -241,7 +241,7 @@ class DefaultConversationRepository @Inject constructor( suspendCoroutine { continuation -> val sessionID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupAPIV2.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) + OpenGroupApiV4.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> diff --git a/liblazysodium/build.gradle b/liblazysodium/build.gradle new file mode 100644 index 0000000000..10f52943d8 --- /dev/null +++ b/liblazysodium/build.gradle @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file('lazysodium.aar')) \ No newline at end of file diff --git a/liblazysodium/lazysodium.aar b/liblazysodium/lazysodium.aar new file mode 100644 index 0000000000..5052e888ed Binary files /dev/null and b/liblazysodium/lazysodium.aar differ diff --git a/libsession/build.gradle b/libsession/build.gradle index 044a4b63c5..4af4add582 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -18,7 +18,8 @@ android { dependencies { implementation project(":libsignal") - implementation 'com.goterl:lazysodium-android:5.0.2@aar' + implementation project(":liblazysodium") +// implementation 'com.goterl:lazysodium-android:5.0.2@aar' implementation "net.java.dev.jna:jna:5.8.0@aar" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation 'androidx.core:core-ktx:1.3.2' diff --git a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt index c229aab4be..e2b14b5398 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt @@ -6,10 +6,10 @@ import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.MediaType import okhttp3.RequestBody -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.snode.OnionRequestAPI -import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log @@ -53,7 +53,7 @@ object FileServerAPIV2 { } private fun send(request: Request): Promise, Exception> { - val url = HttpUrl.parse(server) ?: return Promise.ofFail(OpenGroupAPIV2.Error.InvalidURL) + val url = HttpUrl.parse(server) ?: return Promise.ofFail(OpenGroupApiV4.Error.InvalidURL) val urlBuilder = HttpUrl.Builder() .scheme(url.scheme()) .host(url.host()) @@ -87,7 +87,7 @@ object FileServerAPIV2 { val parameters = mapOf( "file" to base64EncodedFile ) val request = Request(verb = HTTP.Verb.POST, endpoint = "files", parameters = parameters) return send(request).map { json -> - json["result"] as? Long ?: throw OpenGroupAPIV2.Error.ParsingFailed + json["result"] as? Long ?: throw OpenGroupApiV4.Error.ParsingFailed } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 6df64c2d80..cbfdaac3df 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -3,6 +3,7 @@ package org.session.libsession.messaging.jobs import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment @@ -102,7 +103,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } else { val url = HttpUrl.parse(attachment.url)!! val fileID = url.pathSegments().last() - OpenGroupAPIV2.download(fileID.toLong(), openGroupV2.room, openGroupV2.server).get().let { + OpenGroupApiV4.download(fileID.toLong(), openGroupV2.room, openGroupV2.server).get().let { tempFile.writeBytes(it) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index 02475c39cb..431c9ec935 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -9,6 +9,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.file_server.FileServerAPIV2 import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.DecodedAudio @@ -53,7 +54,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess val v2OpenGroup = storage.getV2OpenGroup(threadID.toLong()) if (v2OpenGroup != null) { val keyAndResult = upload(attachment, v2OpenGroup.server, false) { - OpenGroupAPIV2.upload(it, v2OpenGroup.room, v2OpenGroup.server) + OpenGroupApiV4.upload(it, v2OpenGroup.room, v2OpenGroup.server) } handleSuccess(attachment, keyAndResult.first, keyAndResult.second) } else { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt index 227944a889..ccb0ea8e2a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt @@ -1,7 +1,7 @@ package org.session.libsession.messaging.jobs import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.GroupUtil @@ -15,8 +15,8 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job { override fun execute() { val storage = MessagingModuleConfiguration.shared.storage try { - val info = OpenGroupAPIV2.getInfo(room, server).get() - val bytes = OpenGroupAPIV2.downloadOpenGroupProfilePicture(info.id, server).get() + val info = OpenGroupApiV4.getInfo(room, server).get() + val bytes = OpenGroupApiV4.downloadOpenGroupProfilePicture(info.id, server).get() val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) storage.updateProfilePicture(groupId, bytes) storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApiV4.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApiV4.kt new file mode 100644 index 0000000000..5a21cb44a8 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApiV4.kt @@ -0,0 +1,665 @@ +package org.session.libsession.messaging.open_groups + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategy +import com.fasterxml.jackson.databind.annotation.JsonNaming +import com.fasterxml.jackson.databind.type.TypeFactory +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid +import kotlinx.coroutines.flow.MutableSharedFlow +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.bind +import nl.komponents.kovenant.functional.map +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.RequestBody +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV4.Companion.maxInactivityPeriod +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.AESGCM +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Base64.decode +import org.session.libsignal.utilities.Base64.encodeBytes +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.HTTP.Verb.DELETE +import org.session.libsignal.utilities.HTTP.Verb.GET +import org.session.libsignal.utilities.HTTP.Verb.POST +import org.session.libsignal.utilities.HTTP.Verb.PUT +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removing05PrefixIfNeeded +import org.session.libsignal.utilities.toHexString +import org.whispersystems.curve25519.Curve25519 +import java.util.concurrent.TimeUnit +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set + +object OpenGroupApiV4 { + private val moderators: HashMap> = + hashMapOf() // Server URL to (channel ID to set of moderator IDs) + private val curve = Curve25519.getInstance(Curve25519.BEST) + val defaultRooms = MutableSharedFlow>(replay = 1) + private val hasPerformedInitialPoll = mutableMapOf() + private var hasUpdatedLastOpenDate = false + private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } + private val timeSinceLastOpen by lazy { + val context = MessagingModuleConfiguration.shared.context + val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context) + val now = System.currentTimeMillis() + now - lastOpenDate + } + + private const val defaultServerPublicKey = + "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" + const val defaultServer = "http://116.203.70.33" + + sealed class Error(message: String) : Exception(message) { + object Generic : Error("An error occurred.") + object ParsingFailed : Error("Invalid response.") + object DecryptionFailed : Error("Couldn't decrypt response.") + object SigningFailed : Error("Couldn't sign message.") + object InvalidURL : Error("Invalid URL.") + object NoPublicKey : Error("Couldn't find server public key.") + object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") + } + + data class DefaultGroup(val id: String, val name: String, val image: ByteArray?) { + + val joinURL: String get() = "$defaultServer/$id?public_key=$defaultServerPublicKey" + } + + data class Info(val id: String, val name: String, val imageID: String?) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class BatchRequest( + val roomID: String, + val authToken: String, + val fromDeletionServerID: Long?, + val fromMessageServerID: Long? + ) + + data class BatchResult( + val messages: List, + val deletions: List, + val moderators: List + ) + + data class MessageDeletion( + @JsonProperty("id") + val id: Long = 0, + @JsonProperty("deleted_message_id") + val deletedMessageServerID: Long = 0 + ) { + + companion object { + val empty = MessageDeletion() + } + } + + data class Request( + val verb: HTTP.Verb, + val room: String?, + val server: String, + val endpoint: String, + val queryParameters: Map = mapOf(), + val parameters: Any? = null, + val headers: Map = mapOf(), + val isAuthRequired: Boolean = true, + /** + * Always `true` under normal circumstances. You might want to disable + * this when running over Lokinet. + */ + val useOnionRouting: Boolean = true, + val isBlinded: Boolean = true + ) + + private fun createBody(parameters: Any?): RequestBody? { + if (parameters == null) return null + val parametersAsJSON = JsonUtil.toJson(parameters) + return RequestBody.create(MediaType.get("application/json"), parametersAsJSON) + } + + private fun send(request: Request): Promise, Exception> { + val url = HttpUrl.parse(request.server) ?: return Promise.ofFail(Error.InvalidURL) + val urlBuilder = HttpUrl.Builder() + .scheme(url.scheme()) + .host(url.host()) + .port(url.port()) + .addPathSegments(request.endpoint) + if (request.verb == GET) { + for ((key, value) in request.queryParameters) { + urlBuilder.addQueryParameter(key, value) + } + } + fun execute(token: String?): Promise, Exception> { + val publicKey = + MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) + ?: return Promise.ofFail(Error.NoPublicKey) + val ed25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() + ?: return Promise.ofFail(Error.NoEd25519KeyPair) + val urlRequest = urlBuilder.build() + val headers = request.headers.toMutableMap() + val nonce = sodium.nonce(16) + val timestamp = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + var pubKey = "" + var signature = ByteArray(0) + if (request.isBlinded) { + SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> + pubKey = SodiumUtilities.SessionId( + SodiumUtilities.IdPrefix.BLINDED, + keyPair.publicKey.asBytes + ).hexString + signature = SodiumUtilities.sogsSignature( + urlRequest.toString().toByteArray(), + ed25519KeyPair.secretKey.asBytes, + keyPair.secretKey.asBytes, + keyPair.publicKey.asBytes + ) ?: return Promise.ofFail(Error.SigningFailed) + } ?: return Promise.ofFail(Error.SigningFailed) + } else { + pubKey = SodiumUtilities.SessionId( + SodiumUtilities.IdPrefix.UN_BLINDED, + ed25519KeyPair.publicKey.asBytes + ).hexString + signature = ByteArray(0) + } + headers["X-SOGS-Nonce"] = encodeBytes(nonce) + headers["X-SOGS-Timestamp"] = "$timestamp" + headers["X-SOGS-Pubkey"] = pubKey + headers["X-SOGS-Signature"] = encodeBytes(signature) + headers.forEach { entry -> Log.d("Loki", "${entry.key}: ${entry.value}") } + + val requestBuilder = okhttp3.Request.Builder() + .url(urlRequest) + .headers(Headers.of(headers)) + if (request.isAuthRequired) { + if (token.isNullOrEmpty()) throw IllegalStateException("No auth token for request.") + requestBuilder.header("Authorization", token) + } + when (request.verb) { + GET -> requestBuilder.get() + PUT -> requestBuilder.put(createBody(request.parameters)!!) + POST -> requestBuilder.post(createBody(request.parameters)!!) + DELETE -> requestBuilder.delete(createBody(request.parameters)) + } + if (!request.room.isNullOrEmpty()) { + requestBuilder.header("Room", request.room) + } + if (request.useOnionRouting) { + return OnionRequestAPI.sendOnionRequest( + requestBuilder.build(), + request.server, + publicKey + ).fail { e -> + // A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an + // indication that the token we're using has expired. Note that a 403 has a different meaning; it means that + // we provided a valid token but it doesn't have a high enough permission level for the route in question. + if (e is OnionRequestAPI.HTTPRequestFailedAtDestinationException && e.statusCode == 401) { + val storage = MessagingModuleConfiguration.shared.storage + if (request.room != null) { + storage.removeAuthToken(request.room, request.server) + } + } + } + } else { + return Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) + } + } + return if (request.isAuthRequired) { + getAuthToken(request.room!!, request.server).bind { execute(it) } + } else { + execute(null) + } + } + + fun downloadOpenGroupProfilePicture( + roomID: String, + server: String + ): Promise { + val request = Request( + verb = GET, + room = roomID, + server = server, + endpoint = "rooms/$roomID/image", + isAuthRequired = false + ) + return send(request).map { json -> + val result = json["result"] as? String ?: throw Error.ParsingFailed + decode(result) + } + } + + // region Authorization + fun getAuthToken(room: String, server: String): Promise { + val storage = MessagingModuleConfiguration.shared.storage + return storage.getAuthToken(room, server)?.let { + Promise.of(it) + } ?: run { + requestNewAuthToken(room, server) + .bind { claimAuthToken(it, room, server) } + .success { authToken -> + storage.setAuthToken(room, server, authToken) + } + } + } + + fun requestNewAuthToken(room: String, server: String): Promise { + val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair() + .let { it.publicKey.serialize() to it.privateKey.serialize() } + ?: return Promise.ofFail(Error.Generic) + val queryParameters = mutableMapOf("public_key" to publicKey.toHexString()) + val request = Request( + GET, + room, + server, + "auth_token_challenge", + queryParameters, + isAuthRequired = false, + parameters = null + ) + return send(request).map { json -> + val challenge = json["challenge"] as? Map<*, *> ?: throw Error.ParsingFailed + val base64EncodedCiphertext = + challenge["ciphertext"] as? String ?: throw Error.ParsingFailed + val base64EncodedEphemeralPublicKey = + challenge["ephemeral_public_key"] as? String ?: throw Error.ParsingFailed + val ciphertext = decode(base64EncodedCiphertext) + val ephemeralPublicKey = decode(base64EncodedEphemeralPublicKey) + val symmetricKey = AESGCM.generateSymmetricKey(ephemeralPublicKey, privateKey) + val tokenAsData = try { + AESGCM.decrypt(ciphertext, symmetricKey) + } catch (e: Exception) { + throw Error.DecryptionFailed + } + tokenAsData.toHexString() + } + } + + fun claimAuthToken( + authToken: String, + room: String, + server: String + ): Promise { + val parameters = + mapOf("public_key" to MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!) + val headers = mapOf("Authorization" to authToken) + val request = Request( + verb = POST, room = room, server = server, endpoint = "claim_auth_token", + parameters = parameters, headers = headers, isAuthRequired = false + ) + return send(request).map { authToken } + } + + // endregion + + // region Upload/Download + fun upload(file: ByteArray, room: String, server: String): Promise { + val base64EncodedFile = encodeBytes(file) + val parameters = mapOf("file" to base64EncodedFile) + val request = Request( + verb = POST, + room = room, + server = server, + endpoint = "files", + parameters = parameters + ) + return send(request).map { json -> + (json["result"] as? Number)?.toLong() ?: throw Error.ParsingFailed + } + } + + fun download(file: Long, room: String, server: String): Promise { + val request = Request(verb = GET, room = room, server = server, endpoint = "files/$file") + return send(request).map { json -> + val base64EncodedFile = json["result"] as? String ?: throw Error.ParsingFailed + decode(base64EncodedFile) ?: throw Error.ParsingFailed + } + } + // endregion + + // region Sending + fun send( + message: OpenGroupMessageV2, + room: String, + server: String + ): Promise { + val signedMessage = message.sign() ?: return Promise.ofFail(Error.SigningFailed) + val jsonMessage = signedMessage.toJSON() + val request = Request( + verb = POST, + room = room, + server = server, + endpoint = "messages", + parameters = jsonMessage + ) + return send(request).map { json -> + @Suppress("UNCHECKED_CAST") val rawMessage = json["message"] as? Map + ?: throw Error.ParsingFailed + val result = OpenGroupMessageV2.fromJSON(rawMessage) ?: throw Error.ParsingFailed + val storage = MessagingModuleConfiguration.shared.storage + storage.addReceivedMessageTimestamp(result.sentTimestamp) + result + } + } + // endregion + + // region Messages + fun getMessages(room: String, server: String): Promise, Exception> { + val storage = MessagingModuleConfiguration.shared.storage + val queryParameters = mutableMapOf() + storage.getLastMessageServerID(room, server)?.let { lastId -> + queryParameters += "from_server_id" to lastId.toString() + } + val request = Request( + verb = GET, + room = room, + server = server, + endpoint = "messages", + queryParameters = queryParameters + ) + return send(request).map { json -> + @Suppress("UNCHECKED_CAST") val rawMessages = + json["messages"] as? List> + ?: throw Error.ParsingFailed + parseMessages(room, server, rawMessages) + } + } + + private fun parseMessages( + room: String, + server: String, + rawMessages: List> + ): List { + val messages = rawMessages.mapNotNull { json -> + json as Map + try { + val message = OpenGroupMessageV2.fromJSON(json) ?: return@mapNotNull null + if (message.serverID == null || message.sender.isNullOrEmpty()) return@mapNotNull null + val sender = message.sender + val data = decode(message.base64EncodedData) + val signature = decode(message.base64EncodedSignature) + val publicKey = Hex.fromStringCondensed(sender.removing05PrefixIfNeeded()) + val isValid = curve.verifySignature(publicKey, data, signature) + if (!isValid) { + Log.d("Loki", "Ignoring message with invalid signature.") + return@mapNotNull null + } + message + } catch (e: Exception) { + null + } + } + return messages + } + // endregion + + // region Message Deletion + @JvmStatic + fun deleteMessage(serverID: Long, room: String, server: String): Promise { + val request = + Request(verb = DELETE, room = room, server = server, endpoint = "messages/$serverID") + return send(request).map { + Log.d("Loki", "Message deletion successful.") + } + } + + fun getDeletedMessages( + room: String, + server: String + ): Promise, Exception> { + val storage = MessagingModuleConfiguration.shared.storage + val queryParameters = mutableMapOf() + storage.getLastDeletionServerID(room, server)?.let { last -> + queryParameters["from_server_id"] = last.toString() + } + val request = Request( + verb = GET, + room = room, + server = server, + endpoint = "deleted_messages", + queryParameters = queryParameters + ) + return send(request).map { json -> + val type = TypeFactory.defaultInstance() + .constructCollectionType(List::class.java, MessageDeletion::class.java) + val idsAsString = JsonUtil.toJson(json["ids"]) + val serverIDs = JsonUtil.fromJson>(idsAsString, type) + ?: throw Error.ParsingFailed + val lastMessageServerId = storage.getLastDeletionServerID(room, server) ?: 0 + val serverID = serverIDs.maxByOrNull { it.id } ?: MessageDeletion.empty + if (serverID.id > lastMessageServerId) { + storage.setLastDeletionServerID(room, server, serverID.id) + } + serverIDs + } + } + // endregion + + // region Moderation + private fun handleModerators(serverRoomId: String, moderatorList: List) { + moderators[serverRoomId] = moderatorList.toMutableSet() + } + + fun getModerators(room: String, server: String): Promise, Exception> { + val request = Request(verb = GET, room = room, server = server, endpoint = "moderators") + return send(request).map { json -> + @Suppress("UNCHECKED_CAST") val moderatorsJson = json["moderators"] as? List + ?: throw Error.ParsingFailed + val id = "$server.$room" + handleModerators(id, moderatorsJson) + moderatorsJson + } + } + + @JvmStatic + fun ban(publicKey: String, room: String, server: String): Promise { + val parameters = mapOf("public_key" to publicKey) + val request = Request( + verb = POST, + room = room, + server = server, + endpoint = "block_list", + parameters = parameters + ) + return send(request).map { + Log.d("Loki", "Banned user: $publicKey from: $server.$room.") + } + } + + fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise { + val parameters = mapOf("public_key" to publicKey) + val request = Request( + verb = POST, + room = room, + server = server, + endpoint = "ban_and_delete_all", + parameters = parameters + ) + return send(request).map { + Log.d("Loki", "Banned user: $publicKey from: $server.$room.") + } + } + + fun unban(publicKey: String, room: String, server: String): Promise { + val request = + Request(verb = DELETE, room = room, server = server, endpoint = "block_list/$publicKey") + return send(request).map { + Log.d("Loki", "Unbanned user: $publicKey from: $server.$room") + } + } + + @JvmStatic + fun isUserModerator(publicKey: String, room: String, server: String): Boolean = + moderators["$server.$room"]?.contains(publicKey) ?: false + // endregion + + // region General + @Suppress("UNCHECKED_CAST") + fun batch( + rooms: List, + server: String + ): Promise, Exception> { + val authTokenRequests = rooms.associateWith { room -> getAuthToken(room, server) } + val storage = MessagingModuleConfiguration.shared.storage + val context = MessagingModuleConfiguration.shared.context + val timeSinceLastOpen = this.timeSinceLastOpen + val useMessageLimit = (hasPerformedInitialPoll[server] != true + && timeSinceLastOpen > maxInactivityPeriod) + hasPerformedInitialPoll[server] = true + if (!hasUpdatedLastOpenDate) { + hasUpdatedLastOpenDate = true + TextSecurePreferences.setLastOpenDate(context) + } + val requests = rooms.mapNotNull { room -> + val authToken = try { + authTokenRequests[room]?.get() + } catch (e: Exception) { + Log.e("Loki", "Failed to get auth token for $room.", e) + null + } ?: return@mapNotNull null + BatchRequest( + roomID = room, + authToken = authToken, + fromDeletionServerID = if (useMessageLimit) null else storage.getLastDeletionServerID( + room, + server + ), + fromMessageServerID = if (useMessageLimit) null else storage.getLastMessageServerID( + room, + server + ) + ) + } + val request = Request( + verb = POST, + room = null, + server = server, + endpoint = "batch", + isAuthRequired = false, + parameters = mapOf("requests" to requests) + ) + return send(request = request).map { json -> + val results = json["results"] as? List<*> ?: throw Error.ParsingFailed + results.mapNotNull { json -> + if (json !is Map<*, *>) return@mapNotNull null + val roomID = json["room_id"] as? String ?: return@mapNotNull null + // A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an + // indication that the token we're using has expired. Note that a 403 has a different meaning; it means that + // we provided a valid token but it doesn't have a high enough permission level for the route in question. + val statusCode = json["status_code"] as? Int ?: return@mapNotNull null + if (statusCode == 401) { + // delete auth token and return null + storage.removeAuthToken(roomID, server) + } + // Moderators + val moderators = json["moderators"] as? List ?: return@mapNotNull null + handleModerators("$server.$roomID", moderators) + // Deletions + val type = TypeFactory.defaultInstance() + .constructCollectionType(List::class.java, MessageDeletion::class.java) + val idsAsString = JsonUtil.toJson(json["deletions"]) + val deletions = JsonUtil.fromJson>(idsAsString, type) + ?: throw Error.ParsingFailed + // Messages + val rawMessages = + json["messages"] as? List> ?: return@mapNotNull null + val messages = parseMessages(roomID, server, rawMessages) + roomID to BatchResult( + messages = messages, + deletions = deletions, + moderators = moderators + ) + }.toMap() + } + } + + fun getDefaultRoomsIfNeeded(): Promise, Exception> { + val storage = MessagingModuleConfiguration.shared.storage + storage.setOpenGroupPublicKey(defaultServer, defaultServerPublicKey) + return getAllRooms(defaultServer).map { groups -> + val earlyGroups = groups.map { group -> + DefaultGroup(group.id, group.name, null) + } + // See if we have any cached rooms, and if they already have images don't overwrite them with early non-image results + defaultRooms.replayCache.firstOrNull()?.let { replayed -> + if (replayed.none { it.image?.isNotEmpty() == true }) { + defaultRooms.tryEmit(earlyGroups) + } + } + val images = groups.associate { group -> + group.id to downloadOpenGroupProfilePicture(group.id, defaultServer) + } + groups.map { group -> + val image = try { + images[group.id]!!.get() + } catch (e: Exception) { + // No image or image failed to download + null + } + DefaultGroup(group.id, group.name, image) + } + }.success { new -> + defaultRooms.tryEmit(new) + } + } + + fun getInfo(room: String, server: String): Promise { + val request = Request( + verb = GET, + room = null, + server = server, + endpoint = "rooms/$room", + isAuthRequired = false + ) + return send(request).map { json -> + val rawRoom = json["room"] as? Map<*, *> ?: throw Error.ParsingFailed + val id = rawRoom["id"] as? String ?: throw Error.ParsingFailed + val name = rawRoom["name"] as? String ?: throw Error.ParsingFailed + val imageID = rawRoom["image_id"] as? String + Info(id = id, name = name, imageID = imageID) + } + } + + fun getAllRooms(server: String): Promise, Exception> { + val request = Request( + verb = GET, + room = null, + server = server, + endpoint = "rooms", + isAuthRequired = false + ) + return send(request).map { json -> + val rawRooms = json["rooms"] as? List> ?: throw Error.ParsingFailed + rawRooms.mapNotNull { + val roomJson = it as? Map<*, *> ?: return@mapNotNull null + val id = roomJson["id"] as? String ?: return@mapNotNull null + val name = roomJson["name"] as? String ?: return@mapNotNull null + val imageID = roomJson["image_id"] as? String + Info(id, name, imageID) + } + } + } + + fun getMemberCount(room: String, server: String): Promise { + val request = Request(verb = GET, room = room, server = server, endpoint = "active_users") + return send(request).map { json -> + val activeUserCount = json["active_users"] as? Int ?: throw Error.ParsingFailed + val storage = MessagingModuleConfiguration.shared.storage + storage.setUserCount(room, server, activeUserCount) + activeUserCount + } + } + + fun getCapabilities(room: String, server: String): Promise, Exception> { + val request = Request(verb = GET, room = room, server = server, endpoint = "capabilities") + return send(request).map { json -> + json["capabilities"] as? List ?: throw Error.ParsingFailed + } + } + // endregion +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt index 6eb964e9ef..63e3adcd0d 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt @@ -9,15 +9,17 @@ data class OpenGroupV2( val room: String, val id: String, val name: String, - val publicKey: String + val publicKey: String, + val capabilities: List ) { - constructor(server: String, room: String, name: String, publicKey: String) : this( + constructor(server: String, room: String, name: String, publicKey: String, capabilities: List) : this( server = server, room = room, id = "$server.$room", name = name, publicKey = publicKey, + capabilities = capabilities, ) companion object { @@ -30,7 +32,8 @@ data class OpenGroupV2( val server = json.get("server").asText().toLowerCase(Locale.US) val displayName = json.get("displayName").asText() val publicKey = json.get("publicKey").asText() - OpenGroupV2(server, room, displayName, publicKey) + val capabilities = json.get("capabilities")?.asText()?.split(",") ?: emptyList() + OpenGroupV2(server, room, displayName, publicKey, capabilities) } catch (e: Exception) { Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e); null @@ -44,6 +47,7 @@ data class OpenGroupV2( "server" to server, "displayName" to name, "publicKey" to publicKey, + "capabilities" to capabilities.joinToString(","), ) val joinURL: String get() = "$server/$room?public_key=$publicKey" diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index a1a7859a2f..482b3fd231 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -235,7 +235,7 @@ object MessageSender { sentTimestamp = message.sentTimestamp!!, base64EncodedData = Base64.encodeBytes(plaintext), ) - OpenGroupAPIV2.send(openGroupMessage,room,server).success { + OpenGroupApiV4.send(openGroupMessage,room,server).success { message.openGroupServerMessageID = it.serverID handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = it.sentTimestamp) deferred.resolve(Unit) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV4.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV4.kt new file mode 100644 index 0000000000..fbf571caf0 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerV4.kt @@ -0,0 +1,126 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.map +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.BatchMessageReceiveJob +import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageReceiveJob +import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.open_groups.OpenGroupApiV4 +import org.session.libsession.messaging.open_groups.OpenGroupMessageV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.successBackground +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.math.max + +class OpenGroupPollerV4(private val server: String, private val executorService: ScheduledExecutorService?) { + var hasStarted = false + var isCaughtUp = false + var secondToLastJob: MessageReceiveJob? = null + private var future: ScheduledFuture<*>? = null + + companion object { + private const val pollInterval: Long = 4000L + const val maxInactivityPeriod = 14 * 24 * 60 * 60 * 1000 + } + + fun startIfNeeded() { + if (hasStarted) { return } + hasStarted = true + future = executorService?.schedule(::poll, 0, TimeUnit.MILLISECONDS) + } + + fun stop() { + future?.cancel(false) + hasStarted = false + } + + fun poll(): Promise { + val storage = MessagingModuleConfiguration.shared.storage + val rooms = storage.getAllV2OpenGroups().values.filter { it.server == server }.map { it.room } + rooms.forEach { downloadGroupAvatarIfNeeded(it) } + return OpenGroupApiV4.batch(rooms, server).successBackground { responses -> + responses.forEach { (room, response) -> + val openGroupID = "$server.$room" + handleNewMessages(room, openGroupID, response.messages) + handleDeletedMessages(room, openGroupID, response.deletions) + if (secondToLastJob == null && !isCaughtUp) { + isCaughtUp = true + } + } + }.always { + executorService?.schedule(this@OpenGroupPollerV4::poll, pollInterval, TimeUnit.MILLISECONDS) + }.map { } + } + + private fun handleNewMessages( + room: String, + openGroupID: String, + messages: List + ) { + val storage = MessagingModuleConfiguration.shared.storage + val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) + // check thread still exists + val threadId = storage.getThreadId(Address.fromSerialized(groupID)) ?: -1 + val threadExists = threadId >= 0 + if (!hasStarted || !threadExists) { return } + val envelopes = messages.sortedBy { it.serverID!! }.map { message -> + val senderPublicKey = message.sender!! + val builder = SignalServiceProtos.Envelope.newBuilder() + builder.type = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE + builder.source = senderPublicKey + builder.sourceDevice = 1 + builder.content = message.toProto().toByteString() + builder.timestamp = message.sentTimestamp + builder.build() to message.serverID + } + + envelopes.chunked(256).forEach { list -> + val parameters = list.map { (message, serverId) -> + MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId) + } + JobQueue.shared.add(BatchMessageReceiveJob(parameters, openGroupID)) + } + + val currentLastMessageServerID = storage.getLastMessageServerID(room, server) ?: 0 + val actualMax = max(messages.mapNotNull { it.serverID }.maxOrNull() ?: 0, currentLastMessageServerID) + if (actualMax > 0) { + storage.setLastMessageServerID(room, server, actualMax) + } + } + + private fun handleDeletedMessages(room: String, openGroupID: String, deletions: List) { + val storage = MessagingModuleConfiguration.shared.storage + val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider + val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) + val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return + val deletedMessageIDs = deletions.mapNotNull { deletion -> + dataProvider.getMessageID(deletion.deletedMessageServerID, threadID) + } + deletedMessageIDs.forEach { (messageId, isSms) -> + MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(messageId, isSms) + } + val currentMax = storage.getLastDeletionServerID(room, server) ?: 0L + val latestMax = deletions.map { it.id }.maxOrNull() ?: 0L + if (latestMax > currentMax && latestMax != 0L) { + storage.setLastDeletionServerID(room, server, latestMax) + } + } + + private fun downloadGroupAvatarIfNeeded(room: String) { + val storage = MessagingModuleConfiguration.shared.storage + if (storage.getGroupAvatarDownloadJob(server, room) != null) return + val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) + storage.getGroup(groupId)?.let { + if (System.currentTimeMillis() > it.updatedTimestamp + TimeUnit.DAYS.toMillis(7)) { + JobQueue.shared.add(GroupAvatarDownloadJob(room, server)) + } + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt new file mode 100644 index 0000000000..6b41773bfb --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt @@ -0,0 +1,202 @@ +package org.session.libsession.messaging.utilities + +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid +import com.goterl.lazysodium.interfaces.GenericHash +import com.goterl.lazysodium.interfaces.Hash +import com.goterl.lazysodium.interfaces.Sign +import com.goterl.lazysodium.utils.Key +import com.goterl.lazysodium.utils.KeyPair +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.toHexString +import kotlin.experimental.inv + +object SodiumUtilities { + + private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } + + /* 64-byte blake2b hash then reduce to get the blinding factor */ + private fun generateBlindingFactor(serverPublicKey: String): ByteArray? { + // k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) + val serverPubKeyData = Hex.fromStringCondensed(serverPublicKey) + val serverPubKeyHash = ByteArray(GenericHash.BYTES_MAX) + if (!sodium.cryptoGenericHash(serverPubKeyHash, serverPubKeyHash.size, serverPubKeyData, serverPubKeyData.size.toLong())) { + return null + } + // Reduce the server public key into an ed25519 scalar (`k`) + val x25519PublicKey = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) + sodium.cryptoCoreEd25519ScalarReduce(x25519PublicKey, serverPubKeyHash) + return if (x25519PublicKey.any { it.toInt() != 0 }) { + x25519PublicKey + } else null + } + + /* + Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to + convert to an *x* secret key, which seems wrong--but isn't because converted keys use the + same secret scalar secret (and so this is just the most convenient way to get 'a' out of + a sodium Ed25519 secret key) + */ + private fun generatePrivateKeyScalar(secretKey: ByteArray): ByteArray? { + // a = s.to_curve25519_private_key().encode() + val aBytes = ByteArray(Sign.PUBLICKEYBYTES) + return if (sodium.convertSecretKeyEd25519ToCurve25519(aBytes, secretKey)) { + aBytes + } else null + } + + /* Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` */ + fun blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair): KeyPair? { +// if (edKeyPair.publicKey.asBytes.size != Sign.PUBLICKEYBYTES || +// edKeyPair.secretKey.asBytes.size != Sign.SECRETKEYBYTES) return null + val kBytes = generateBlindingFactor(serverPublicKey) + val aBytes = generatePrivateKeyScalar(edKeyPair.secretKey.asBytes) + // Generate the blinded key pair `ka`, `kA` + val kaBytes = ByteArray(Sign.SECRETKEYBYTES) + sodium.cryptoCoreEd25519ScalarMul(kaBytes, kBytes, aBytes) + if (kaBytes.all { it.toInt() == 0 }) { + return null + } + val kABytes = ByteArray(Sign.PUBLICKEYBYTES) + sodium.cryptoScalarMultE25519BaseNoClamp(kABytes, kaBytes) + return if (kABytes.any { it.toInt() != 0 }) { + KeyPair(Key.fromBytes(kABytes), Key.fromBytes(kaBytes)) + } else { + null + } + } + + /* + Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the + construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded + pubkeys (this doesn't affect verification at all) + */ + fun sogsSignature( + message: ByteArray, + secretKey: ByteArray, + ka: ByteArray, /*blindedSecretKey*/ + kA: ByteArray /*blindedPublicKey*/ + ): ByteArray? { + // H_rh = sha512(s.encode()).digest()[32:] + val h_rh = ByteArray(Hash.SHA512_BYTES) + if (!sodium.cryptoHashSha512(h_rh, secretKey, secretKey.size.toLong())) return null + + // r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) + val combinedData = h_rh + kA + message + val combinedHash = ByteArray(Hash.SHA512_BYTES) + if (!sodium.cryptoHashSha512(combinedHash, combinedData, combinedData.size.toLong())) return null + val rHash = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) + sodium.cryptoCoreEd25519ScalarReduce(rHash, combinedHash) + if (rHash.all { it.toInt() == 0 }) { + return null + } + + // sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) + val sig_R = ByteArray(Sign.SECRETKEYBYTES) + if (!sodium.cryptoScalarMultBase(sig_R, rHash)) return null + + // HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) + val hRamData = sig_R + kA + message + val hRamHash = ByteArray(Hash.SHA512_BYTES) + if (!sodium.cryptoHashSha512(hRamHash, hRamData, hRamData.size.toLong())) return null + val hRam = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) + sodium.cryptoCoreEd25519ScalarReduce(hRam, hRamHash) + if (hRam.all { it.toInt() == 0 }) { + return null + } + // sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) + val sig_sMul = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) + val sig_s = ByteArray(Sign.CURVE25519_PUBLICKEYBYTES) + if (sodium.cryptoScalarMult(sig_sMul, hRam, ka)) { + sodium.cryptoCoreEd25519ScalarReduce(sig_s/*, rHash*/, sig_sMul) + } else return null + + return sig_R + sig_s + } + + /* Combines two keys (`kA`) */ + private fun combineKeys(lhsKey: ByteArray, rhsKey: ByteArray): ByteArray? { + return sodium.cryptoScalarMult(Key.fromBytes(lhsKey), Key.fromBytes(rhsKey)).asBytes + } + + /* + Calculate a shared secret for a message from A to B: + BLAKE2b(a kB || kA || kB) + The receiver can calculate the same value via: + BLAKE2b(b kA || kA || kB) + */ + fun sharedBlindedEncryptionKey( + secretKey: ByteArray, + otherBlindedPublicKey: ByteArray, + kA: ByteArray, /*fromBlindedPublicKey*/ + kB: ByteArray /*toBlindedPublicKey*/ + ): ByteArray? { + val aBytes = generatePrivateKeyScalar(secretKey) ?: return null + val combinedKeyBytes = combineKeys(aBytes, otherBlindedPublicKey) ?: return null + val outputHash = ByteArray(GenericHash.KEYBYTES) + val inputBytes = combinedKeyBytes + kA + kB + return if (sodium.cryptoGenericHash(outputHash, outputHash.size, inputBytes, inputBytes.size.toLong())) { + outputHash + } else null + } + + /* This method should be used to check if a users standard sessionId matches a blinded one */ + fun sessionId( + standardSessionId: String, + blindedSessionId: String, + serverPublicKey: String + ): Boolean { + // Only support generating blinded keys for standard session ids + val sessionId = SessionId(standardSessionId) + if (sessionId.prefix != IdPrefix.STANDARD) return false + val blindedId = SessionId(blindedSessionId) + if (blindedId.prefix != IdPrefix.BLINDED) return false + val k = generateBlindingFactor(serverPublicKey) ?: return false + + // From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what + // Signal's XEd25519 conversion always uses) + val xEd25519Key = Key.fromHexString(sessionId.publicKey).asBytes + + // Blind the positive public key + val pk1 = combineKeys(k, xEd25519Key) ?: return false + + // For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2 + // pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) + val pk2 = pk1.take(31).toByteArray() + listOf(pk1.last().inv()).toByteArray() + + return SessionId(IdPrefix.BLINDED, pk1).publicKey == blindedId.publicKey || + SessionId(IdPrefix.BLINDED, pk2).publicKey == blindedId.publicKey + } + + class SessionId { + var prefix: IdPrefix? + var publicKey: String + + constructor(id: String) { + prefix = IdPrefix.fromValue(id.take(2)) + publicKey = id.drop(2) + } + + constructor(prefix: IdPrefix, publicKey: ByteArray) { + this.prefix = prefix + this.publicKey = publicKey.toHexString() + } + + val hexString + get() = prefix?.prefix + publicKey + } + + enum class IdPrefix(val prefix: String) { + STANDARD("05"), BLINDED("15"), UN_BLINDED("00"); + + companion object { + fun fromValue(prefix: String): IdPrefix? = when(prefix) { + STANDARD.prefix -> STANDARD + BLINDED.prefix -> BLINDED + UN_BLINDED.prefix -> UN_BLINDED + else -> null + } + } + + } +} diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 5a5256c10f..9dccbe4cd5 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -441,7 +441,10 @@ object OnionRequestAPI { * Sends an onion request to `snode`. Builds new paths as needed. */ internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String? = null): Promise, Exception> { - val payload = mapOf( "method" to method.rawValue, "params" to parameters ) + val payload = mapOf( + "method" to method.rawValue, + "params" to parameters + ) return sendOnionRequest(Destination.Snode(snode), payload).recover { exception -> val error = when (exception) { is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index dfbc6c34da..5c12be21ae 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -530,7 +530,7 @@ object SnodeAPI { 400, 500, 502, 503 -> { // Usually indicates that the snode isn't up to date handleBadSnode() } - 406 -> { + 425 -> { Log.d("Loki", "The user's clock is out of sync with the service node network.") broadcaster.broadcast("clockOutOfSync") return Error.ClockOutOfSync diff --git a/settings.gradle b/settings.gradle index 2a51c756e9..3a42510472 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ rootProject.name = "session-android" include ':app' +include ':liblazysodium' include ':libsession' include ':libsignal' \ No newline at end of file