diff --git a/app/build.gradle b/app/build.gradle index 575c1b3b39..7cf615cfb3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -46,11 +46,12 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.0.1' implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-common-java8:2.2.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0' - implementation 'androidx.activity:activity-ktx:1.1.0' - implementation 'androidx.fragment:fragment-ktx:1.3.0-beta01' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' + implementation 'androidx.activity:activity-ktx:1.2.2' + implementation 'androidx.fragment:fragment-ktx:1.3.2' implementation "androidx.core:core-ktx:1.3.2" implementation "androidx.work:work-runtime-ktx:2.4.0" diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index c582f909e1..40981b5cc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -137,9 +137,20 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) return openGroupMessagingDatabase.getMessageID(serverID) } - override fun deleteMessage(messageID: Long) { - val messagingDatabase = DatabaseFactory.getSmsDatabase(context) - messagingDatabase.deleteMessage(messageID) + override fun getMessageID(serverId: Long, threadId: Long): Pair? { + val messageDB = DatabaseFactory.getLokiMessageDatabase(context) + return messageDB.getMessageID(serverId, threadId) + } + + override fun deleteMessage(messageID: Long, isSms: Boolean) { + if (isSms) { + val db = DatabaseFactory.getSmsDatabase(context) + db.deleteMessage(messageID) + } else { + val db = DatabaseFactory.getMmsDatabase(context) + db.delete(messageID) + } + DatabaseFactory.getLokiMessageDatabase(context).deleteMessage(messageID, isSms) } override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 56e44d6b25..178b29cebe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -83,21 +83,42 @@ import com.annimon.stream.Stream; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; - - import org.session.libsession.messaging.mentions.MentionsManager; import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate; +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; +import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage; +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage; import org.session.libsession.messaging.open_groups.OpenGroup; +import org.session.libsession.messaging.open_groups.OpenGroupV2; +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; +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; +import org.session.libsession.messaging.sending_receiving.sharecontacts.Contact; +import org.session.libsession.messaging.threads.Address; import org.session.libsession.messaging.threads.DistributionTypes; +import org.session.libsession.messaging.threads.GroupRecord; +import org.session.libsession.messaging.threads.recipients.Recipient; +import org.session.libsession.messaging.threads.recipients.RecipientFormattingException; +import org.session.libsession.messaging.threads.recipients.RecipientModifiedListener; +import org.session.libsession.utilities.ExpirationUtil; import org.session.libsession.utilities.GroupUtil; import org.session.libsession.utilities.MediaTypes; +import org.session.libsession.utilities.ServiceUtil; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.ViewUtil; +import org.session.libsession.utilities.concurrent.AssertedSuccessListener; +import org.session.libsession.utilities.views.Stub; import org.session.libsignal.libsignal.InvalidMessageException; import org.session.libsignal.libsignal.util.guava.Optional; import org.session.libsignal.service.loki.Mention; import org.session.libsignal.service.loki.utilities.HexEncodingKt; import org.session.libsignal.service.loki.utilities.PublicKeyValidation; +import org.session.libsignal.utilities.concurrent.ListenableFuture; +import org.session.libsignal.utilities.concurrent.SettableFuture; +import org.session.libsignal.utilities.logging.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ExpirationDialog; import org.thoughtcrime.securesms.MediaOverviewActivity; @@ -121,7 +142,6 @@ import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; -import org.session.libsession.messaging.threads.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase.Draft; @@ -135,7 +155,6 @@ import org.thoughtcrime.securesms.giph.ui.GiphyActivity; import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel; -import org.session.libsignal.utilities.logging.Log; import org.thoughtcrime.securesms.loki.activities.EditClosedGroupActivity; import org.thoughtcrime.securesms.loki.activities.HomeActivity; import org.thoughtcrime.securesms.loki.api.PublicChatInfoUpdateWorker; @@ -158,8 +177,6 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.ImageSlide; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.mms.MmsException; -import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage; import org.thoughtcrime.securesms.mms.QuoteId; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; @@ -168,31 +185,12 @@ import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.BlobProvider; -import org.session.libsession.messaging.threads.recipients.Recipient; -import org.session.libsession.messaging.threads.recipients.RecipientFormattingException; -import org.session.libsession.messaging.threads.recipients.RecipientModifiedListener; import org.thoughtcrime.securesms.search.model.MessageResult; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.PushCharacterCalculator; -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.Util; - -import org.session.libsession.messaging.sending_receiving.sharecontacts.Contact; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; -import org.session.libsession.messaging.threads.GroupRecord; -import org.session.libsession.utilities.ExpirationUtil; -import org.session.libsession.utilities.views.Stub; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsession.utilities.concurrent.AssertedSuccessListener; -import org.session.libsignal.utilities.concurrent.ListenableFuture; -import org.session.libsignal.utilities.concurrent.SettableFuture; -import org.session.libsession.utilities.TextSecurePreferences; import java.io.IOException; import java.text.SimpleDateFormat; @@ -377,9 +375,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(threadId, this); OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(this).getPublicChat(threadId); + OpenGroupV2 openGroupV2 = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadId); if (publicChat != null) { // Request open group info update and handle the successful result in #onOpenGroupInfoUpdated(). PublicChatInfoUpdateWorker.scheduleInstant(this, publicChat.getServer(), publicChat.getChannel()); + } else if (openGroupV2 != null) { + PublicChatInfoUpdateWorker.scheduleInstant(this, openGroupV2.getServer(), openGroupV2.getRoom()); } View rootView = findViewById(R.id.rootView); @@ -1400,11 +1401,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity @Subscribe(threadMode = ThreadMode.MAIN) public void onOpenGroupInfoUpdated(OpenGroupUtilities.GroupInfoUpdatedEvent event) { OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(this).getPublicChat(threadId); + OpenGroupV2 openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadId); if (publicChat != null && publicChat.getChannel() == event.getChannel() && publicChat.getServer().equals(event.getUrl())) { this.updateSubtitleTextView(); } + if (openGroup != null && + openGroup.getRoom().equals(event.getRoom()) && + openGroup.getServer().equals(event.getUrl())) { + this.updateSubtitleTextView(); + } } //////// Helper Methods @@ -1721,7 +1728,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity boolean initiating = threadId == -1; boolean needsSplit = message.length() > characterCalculator.calculateCharacters(message).maxPrimaryMessageSize; boolean isMediaMessage = attachmentManager.isAttachmentPresent() || - recipient.isGroupRecipient() || +// recipient.isGroupRecipient() || inputPanel.getQuote().isPresent() || linkPreviewViewModel.hasLinkPreview() || LinkPreviewUtil.isValidMediaUrl(message) || // Loki - Send GIFs as media messages @@ -2338,10 +2345,15 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity subtitleTextView.setText("Muted until " + DateUtils.getFormattedDateTime(recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())); } else if (recipient.isGroupRecipient() && recipient.getName() != null && !recipient.getName().equals("Session Updates") && !recipient.getName().equals("Loki News")) { OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(this).getPublicChat(threadId); + OpenGroupV2 openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadId); if (publicChat != null) { Integer userCount = DatabaseFactory.getLokiAPIDatabase(this).getUserCount(publicChat.getChannel(), publicChat.getServer()); if (userCount == null) { userCount = 0; } subtitleTextView.setText(userCount + " members"); + } else if (openGroup != null) { + Integer userCount = DatabaseFactory.getLokiAPIDatabase(this).getUserCount(openGroup.getRoom(),openGroup.getServer()); + if (userCount == null) { userCount = 0; } + subtitleTextView.setText(userCount + " members"); } else if (PublicKeyValidation.isValid(recipient.getAddress().toString())) { subtitleTextView.setText(recipient.getAddress().toString()); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index e78a2e16f3..53ab9de4ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -57,49 +57,51 @@ import androidx.recyclerview.widget.RecyclerView.OnScrollListener; import com.annimon.stream.Stream; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.messages.control.DataExtractionNotification; +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.Quote; import org.session.libsession.messaging.messages.visible.VisibleMessage; import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.messaging.open_groups.OpenGroupAPI; +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2; +import org.session.libsession.messaging.open_groups.OpenGroupV2; +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; +import org.session.libsession.messaging.threads.Address; +import org.session.libsession.messaging.threads.recipients.Recipient; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.ViewUtil; +import org.session.libsession.utilities.concurrent.SimpleTask; +import org.session.libsession.utilities.task.ProgressDialogAsyncTask; +import org.session.libsignal.libsignal.util.guava.Optional; +import org.session.libsignal.utilities.logging.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.MessageDetailsActivity; import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.thoughtcrime.securesms.ShareActivity; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.thoughtcrime.securesms.components.ConversationTypingView; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.conversation.ConversationAdapter.HeaderViewHolder; import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener; -import org.session.libsession.messaging.threads.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.loaders.ConversationLoader; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.session.libsignal.utilities.logging.Log; import org.thoughtcrime.securesms.longmessage.LongMessageActivity; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.GlideApp; -import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.permissions.Permissions; -import org.session.libsession.messaging.threads.recipients.Recipient; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; -import org.session.libsession.utilities.task.ProgressDialogAsyncTask; -import org.session.libsignal.libsignal.util.guava.Optional; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsession.utilities.concurrent.SimpleTask; import java.io.IOException; import java.io.InputStream; @@ -397,7 +399,8 @@ public class ConversationFragment extends Fragment if (isGroupChat) { OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(getContext()).getPublicChat(threadId); - boolean isPublicChat = (publicChat != null); + OpenGroupV2 openGroupChat = DatabaseFactory.getLokiThreadDatabase(getContext()).getOpenGroupChat(threadId); + boolean isPublicChat = (publicChat != null || openGroupChat != null); int selectedMessageCount = messageRecords.size(); boolean areAllSentByUser = true; Set uniqueUserSet = new HashSet<>(); @@ -407,8 +410,12 @@ public class ConversationFragment extends Fragment } menu.findItem(R.id.menu_context_copy_public_key).setVisible(selectedMessageCount == 1 && !areAllSentByUser); menu.findItem(R.id.menu_context_reply).setVisible(selectedMessageCount == 1); - String userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(getContext()); - boolean userCanModerate = isPublicChat && OpenGroupAPI.isUserModerator(userHexEncodedPublicKey, publicChat.getChannel(), publicChat.getServer()); + String userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(requireContext()); + boolean userCanModerate = + (isPublicChat && + ((publicChat != null && OpenGroupAPI.isUserModerator(userHexEncodedPublicKey, publicChat.getChannel(), publicChat.getServer())) + || (openGroupChat != null && OpenGroupAPIV2.isUserModerator(userHexEncodedPublicKey, openGroupChat.getRoom(), openGroupChat.getServer()))) + ); boolean isDeleteOptionVisible = !isPublicChat || (areAllSentByUser || userCanModerate); // allow banning if moderating a public chat and only one user's messages are selected boolean isBanOptionVisible = isPublicChat && userCanModerate && !areAllSentByUser && uniqueUserSet.size() == 1; @@ -509,6 +516,7 @@ public class ConversationFragment extends Fragment builder.setCancelable(true); OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(getContext()).getPublicChat(threadId); + OpenGroupV2 openGroupChat = DatabaseFactory.getLokiThreadDatabase(getContext()).getOpenGroupChat(threadId); builder.setPositiveButton(R.string.delete, new DialogInterface.OnClickListener() { @Override @@ -519,14 +527,14 @@ public class ConversationFragment extends Fragment { @Override protected Void doInBackground(MessageRecord... messageRecords) { - if (publicChat != null) { + if (publicChat != null || openGroupChat != null) { ArrayList serverIDs = new ArrayList<>(); ArrayList ignoredMessages = new ArrayList<>(); ArrayList failedMessages = new ArrayList<>(); boolean isSentByUser = true; for (MessageRecord messageRecord : messageRecords) { isSentByUser = isSentByUser && messageRecord.isOutgoing(); - Long serverID = DatabaseFactory.getLokiMessageDatabase(getContext()).getServerID(messageRecord.id); + Long serverID = DatabaseFactory.getLokiMessageDatabase(getContext()).getServerID(messageRecord.id, !messageRecord.isMms()); if (serverID != null) { serverIDs.add(serverID); } else { @@ -538,7 +546,7 @@ public class ConversationFragment extends Fragment .deleteMessages(serverIDs, publicChat.getChannel(), publicChat.getServer(), isSentByUser) .success(l -> { for (MessageRecord messageRecord : messageRecords) { - Long serverID = DatabaseFactory.getLokiMessageDatabase(getContext()).getServerID(messageRecord.id); + Long serverID = DatabaseFactory.getLokiMessageDatabase(getContext()).getServerID(messageRecord.id, !messageRecord.isMms()); if (l.contains(serverID)) { if (messageRecord.isMms()) { DatabaseFactory.getMmsDatabase(getActivity()).delete(messageRecord.getId()); @@ -555,7 +563,25 @@ public class ConversationFragment extends Fragment Log.w("Loki", "Couldn't delete message due to error: " + e.toString() + "."); return null; }); - } + } else if (openGroupChat != null) { + for (Long serverId : serverIDs) { + OpenGroupAPIV2 + .deleteMessage(serverId, openGroupChat.getRoom(), openGroupChat.getServer()) + .success(l -> { + for (MessageRecord messageRecord : messageRecords) { + Long serverID = DatabaseFactory.getLokiMessageDatabase(getContext()).getServerID(messageRecord.id, !messageRecord.isMms()); + if (serverID != null && serverID.equals(serverId)) { + MessagingModuleConfiguration.shared.getMessageDataProvider().deleteMessage(messageRecord.id, !messageRecord.isMms()); + break; + } + } + return null; + }).fail(e->{ + Log.e("Loki", "Couldn't delete message due to error",e); + return null; + }); + } + } } else { for (MessageRecord messageRecord : messageRecords) { if (messageRecord.isMms()) { @@ -591,7 +617,8 @@ public class ConversationFragment extends Fragment builder.setTitle(R.string.ConversationFragment_ban_selected_user); builder.setCancelable(true); - OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(getContext()).getPublicChat(threadId); + final OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(getContext()).getPublicChat(threadId); + final OpenGroupV2 openGroupChat = DatabaseFactory.getLokiThreadDatabase(getContext()).getOpenGroupChat(threadId); builder.setPositiveButton(R.string.ban, (dialog, which) -> { ConversationAdapter chatAdapter = getListAdapter(); @@ -610,9 +637,19 @@ public class ConversationFragment extends Fragment Log.d("Loki", "User banned"); return Unit.INSTANCE; }).fail(e -> { - Log.d("Loki", "Couldn't ban user due to error: " + e.toString() + "."); + Log.e("Loki", "Couldn't ban user due to error",e); return null; }); + } else if (openGroupChat != null) { + OpenGroupAPIV2 + .ban(userPublicKey, openGroupChat.getRoom(), openGroupChat.getServer()) + .success(l -> { + Log.d("Loki", "User banned"); + return Unit.INSTANCE; + }).fail(e -> { + Log.e("Loki", "Failed to ban user",e); + return null; + }); } else { Log.d("Loki", "Tried to ban user from a non-public chat"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 6849f9cc7d..b89a7b6962 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -56,6 +56,8 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob; import org.session.libsession.messaging.jobs.JobQueue; import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.messaging.open_groups.OpenGroupAPI; +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2; +import org.session.libsession.messaging.open_groups.OpenGroupV2; import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; @@ -88,6 +90,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.loki.utilities.MentionUtilities; +import org.thoughtcrime.securesms.loki.utilities.OpenGroupUtilities; import org.thoughtcrime.securesms.loki.views.MessageAudioView; import org.thoughtcrime.securesms.loki.views.ProfilePictureView; import org.thoughtcrime.securesms.mms.GlideRequests; @@ -724,9 +727,9 @@ public class ConversationItem extends LinearLayout String publicKey = recipient.getAddress().toString(); profilePictureView.setPublicKey(publicKey); String displayName = recipient.getName(); - OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID); - if (displayName == null && publicChat != null) { - displayName = DatabaseFactory.getLokiUserDatabase(context).getServerDisplayName(publicChat.getId(), publicKey); + OpenGroup openGroup = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID); + if (displayName == null && openGroup != null) { + displayName = DatabaseFactory.getLokiUserDatabase(context).getServerDisplayName(openGroup.getId(), publicKey); } profilePictureView.setDisplayName(displayName); profilePictureView.setAdditionalPublicKey(null); @@ -867,7 +870,12 @@ public class ConversationItem extends LinearLayout try { String serverId = GroupUtil.getDecodedGroupID(conversationRecipient.getAddress().serialize()); String senderDisplayName = DatabaseFactory.getLokiUserDatabase(context).getServerDisplayName(serverId, recipient.getAddress().serialize()); - if (senderDisplayName != null) { displayName = senderDisplayName; } + if (senderDisplayName != null) { + displayName = senderDisplayName; + } else { + // opengroupv2 format + displayName = OpenGroupUtilities.getDisplayName(recipient); + } } catch (Exception e) { // Do nothing } @@ -912,9 +920,13 @@ public class ConversationItem extends LinearLayout int visibility = View.GONE; OpenGroup publicChat = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(messageRecord.getThreadId()); + OpenGroupV2 openGroupV2 = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(messageRecord.getThreadId()); if (publicChat != null) { boolean isModerator = OpenGroupAPI.isUserModerator(current.getRecipient().getAddress().toString(), publicChat.getChannel(), publicChat.getServer()); visibility = isModerator ? View.VISIBLE : View.GONE; + } else if (openGroupV2 != null) { + boolean isModerator = OpenGroupAPIV2.isUserModerator(current.getRecipient().getAddress().toString(), openGroupV2.getRoom(), openGroupV2.getServer()); + visibility = isModerator ? View.VISIBLE : View.GONE; } moderatorIconImageView.setVisibility(visibility); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index eb3b43b224..1c63437a73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -13,6 +13,7 @@ import org.session.libsession.messaging.messages.signal.IncomingTextMessage import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage @@ -226,6 +227,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseFactory.getLokiAPIDatabase(context).setAuthToken(server, null) } + override fun getAuthToken(room: String, server: String): String? { + val id = "$server.$room" + return DatabaseFactory.getLokiAPIDatabase(context).getAuthToken(id) + } + + override fun setAuthToken(room: String, server: String, newValue: String) { + val id = "$server.$room" + DatabaseFactory.getLokiAPIDatabase(context).setAuthToken(id, newValue) + } + + override fun removeAuthToken(room: String, server: String) { + val id = "$server.$room" + DatabaseFactory.getLokiAPIDatabase(context).setAuthToken(id, null) + } + override fun getOpenGroup(threadID: String): OpenGroup? { if (threadID.toInt() < 0) { return null } val database = databaseHelper.readableDatabase @@ -235,6 +251,15 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } + override fun getV2OpenGroup(threadId: String): OpenGroupV2? { + if (threadId.toInt() < 0) { return null } + val database = databaseHelper.readableDatabase + return database.get(LokiThreadDatabase.publicChatTable, "${LokiThreadDatabase.threadID} = ?", arrayOf(threadId)) { cursor -> + val publicChatAsJson = cursor.getString(LokiThreadDatabase.publicChat) + OpenGroupV2.fromJson(publicChatAsJson) + } + } + override fun getThreadID(openGroupID: String): String { val address = Address.fromSerialized(openGroupID) val recipient = Recipient.from(context, address, false) @@ -254,11 +279,33 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseFactory.getLokiUserDatabase(context).setServerDisplayName(groupID, publicKey, displayName) } + override fun setOpenGroupDisplayName(publicKey: String, room: String, server: String, displayName: String) { + val groupID = "$server.$room" + DatabaseFactory.getLokiUserDatabase(context).setServerDisplayName(groupID, publicKey, displayName) + } + override fun getOpenGroupDisplayName(publicKey: String, channel: Long, server: String): String? { val groupID = "$server.$channel" return DatabaseFactory.getLokiUserDatabase(context).getServerDisplayName(groupID, publicKey) } + override fun getOpenGroupDisplayName(publicKey: String, room: String, server: String): String? { + val groupID = "$server.$room" + return DatabaseFactory.getLokiUserDatabase(context).getServerDisplayName(groupID, publicKey) + } + + override fun getLastMessageServerId(room: String, server: String): Long? { + return DatabaseFactory.getLokiAPIDatabase(context).getLastMessageServerID(room, server) + } + + override fun setLastMessageServerId(room: String, server: String, newValue: Long) { + DatabaseFactory.getLokiAPIDatabase(context).setLastMessageServerID(room, server, newValue) + } + + override fun removeLastMessageServerId(room: String, server: String) { + DatabaseFactory.getLokiAPIDatabase(context).removeLastMessageServerID(room, server) + } + override fun getLastMessageServerID(group: Long, server: String): Long? { return DatabaseFactory.getLokiAPIDatabase(context).getLastMessageServerID(group, server) } @@ -271,6 +318,22 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseFactory.getLokiAPIDatabase(context).removeLastMessageServerID(group, server) } + override fun getLastDeletionServerId(room: String, server: String): Long? { + return DatabaseFactory.getLokiAPIDatabase(context).getLastDeletionServerID(room, server) + } + + override fun setLastDeletionServerId(room: String, server: String, newValue: Long) { + DatabaseFactory.getLokiAPIDatabase(context).setLastDeletionServerID(room, server, newValue) + } + + override fun removeLastDeletionServerId(room: String, server: String) { + DatabaseFactory.getLokiAPIDatabase(context).removeLastDeletionServerID(room, server) + } + + override fun setUserCount(room: String, server: String, newValue: Int) { + DatabaseFactory.getLokiAPIDatabase(context).setUserCount(room, server, newValue) + } + override fun getLastDeletionServerID(group: Long, server: String): Long? { return DatabaseFactory.getLokiAPIDatabase(context).getLastDeletionServerID(group, server) } @@ -325,8 +388,9 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return database.getMessageFor(timestamp, address)?.getId() } - override fun setOpenGroupServerMessageID(messageID: Long, serverID: Long) { - DatabaseFactory.getLokiMessageDatabase(context).setServerID(messageID, serverID) + override fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) { + DatabaseFactory.getLokiMessageDatabase(context).setServerID(messageID, serverID, isSms) + DatabaseFactory.getLokiMessageDatabase(context).setOriginalThreadID(messageID, serverID, threadID) } override fun getQuoteServerID(quoteID: Long, publicKey: String): Long? { @@ -475,6 +539,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } + override fun getAllV2OpenGroups(): Map { + return DatabaseFactory.getLokiThreadDatabase(context).getAllV2OpenGroups() + } + override fun addOpenGroup(server: String, channel: Long) { OpenGroupUtilities.addGroup(context, server, channel) } @@ -513,6 +581,15 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return if (threadID < 0) null else threadID } + override fun getThreadIdForMms(mmsId: Long): Long { + val mmsDb = DatabaseFactory.getMmsDatabase(context) + val cursor = mmsDb.getMessage(mmsId) + val reader = mmsDb.readerFor(cursor) + val threadId = reader.next.threadId + cursor.close() + return threadId + } + override fun getSessionRequestSentTimestamp(publicKey: String): Long? { return DatabaseFactory.getLokiAPIDatabase(context).getSessionRequestSentTimestamp(publicKey) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 82a2044884..dc7a0f7ee4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -125,6 +125,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand()); db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand()); db.execSQL(SessionJobDatabase.getCreateSessionJobTableCommand()); + db.execSQL(LokiMessageDatabase.getUpdateMessageIDTableForType()); + db.execSQL(LokiMessageDatabase.getUpdateMessageMappingTable()); executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS); @@ -275,6 +277,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { if (oldVersion < lokiV23) { db.execSQL("ALTER TABLE groups ADD COLUMN zombie_members TEXT"); + db.execSQL(LokiMessageDatabase.getUpdateMessageIDTableForType()); + db.execSQL(LokiMessageDatabase.getUpdateMessageMappingTable()); } db.setTransactionSuccessful(); 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 b458241798..3bc33c9746 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 @@ -353,6 +353,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), withContext(Dispatchers.IO) { val publicChat = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID) + val openGroupV2 = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID) //TODO Move open group related logic to OpenGroupUtilities / PublicChatManager / GroupManager if (publicChat != null) { val apiDB = DatabaseFactory.getLokiAPIDatabase(context) @@ -364,6 +365,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ApplicationContext.getInstance(context).publicChatManager .removeChat(publicChat.server, publicChat.channel) + } else if (openGroupV2 != null) { + val apiDB = DatabaseFactory.getLokiAPIDatabase(context) + apiDB.removeLastMessageServerID(openGroupV2.room, openGroupV2.server) + apiDB.removeLastDeletionServerID(openGroupV2.room, openGroupV2.server) + + ApplicationContext.getInstance(context).publicChatManager + .removeChat(openGroupV2.server, openGroupV2.room) } else { threadDB.deleteConversation(threadID) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt index 79c107c3ca..6686ca8345 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt @@ -2,31 +2,49 @@ package org.thoughtcrime.securesms.loki.activities import android.animation.Animator import android.animation.AnimatorListenerAdapter +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory import android.os.Bundle -import android.util.Patterns import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter +import androidx.activity.viewModels +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import androidx.core.view.isVisible +import androidx.fragment.app.* import androidx.lifecycle.lifecycleScope +import com.google.android.material.chip.Chip import kotlinx.android.synthetic.main.activity_join_public_chat.* import kotlinx.android.synthetic.main.fragment_enter_chat_url.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import okhttp3.HttpUrl +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2.DefaultGroup +import org.session.libsession.messaging.threads.Address +import org.session.libsession.messaging.threads.DistributionTypes +import org.session.libsession.messaging.threads.recipients.Recipient +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.utilities.logging.Log import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.session.libsignal.utilities.logging.Log +import org.thoughtcrime.securesms.conversation.ConversationActivity +import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.loki.protocol.MultiDeviceProtocol import org.thoughtcrime.securesms.loki.utilities.OpenGroupUtilities +import org.thoughtcrime.securesms.loki.viewmodel.DefaultGroupsViewModel +import org.thoughtcrime.securesms.loki.viewmodel.State class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + + private val viewModel by viewModels() + private val adapter = JoinPublicChatActivityAdapter(this) // region Lifecycle @@ -65,16 +83,43 @@ class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCode } fun joinPublicChatIfPossible(url: String) { - if (!Patterns.WEB_URL.matcher(url).matches() || !url.startsWith("https://")) { - return Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show() - } + // add http if just an IP style / host style URL is entered but leave it if scheme is included + val properString = if (!url.startsWith("http")) "http://$url" else url + val httpUrl = HttpUrl.parse(properString) ?: return Toast.makeText(this,R.string.invalid_url, Toast.LENGTH_SHORT).show() + + val room = httpUrl.pathSegments().firstOrNull() + val publicKey = httpUrl.queryParameter("public_key") + val isV2OpenGroup = !room.isNullOrEmpty() showLoader() - val channel: Long = 1 lifecycleScope.launch(Dispatchers.IO) { try { - OpenGroupUtilities.addGroup(this@JoinPublicChatActivity, url, channel) + val (threadID, groupID) = if (isV2OpenGroup) { + val server = HttpUrl.Builder().scheme(httpUrl.scheme()).host(httpUrl.host()).apply { + if (httpUrl.port() != 80 || httpUrl.port() != 443) { + // non-standard port, add to server + this.port(httpUrl.port()) + } + }.build() + val group = OpenGroupUtilities.addGroup(this@JoinPublicChatActivity, server.toString().removeSuffix("/"), room!!, publicKey!!) + val threadID = GroupManager.getOpenGroupThreadID(group.id, this@JoinPublicChatActivity) + val groupID = GroupUtil.getEncodedOpenGroupID(group.id.toByteArray()) + threadID to groupID + } else { + val channel: Long = 1 + val group = OpenGroupUtilities.addGroup(this@JoinPublicChatActivity, properString, channel) + val threadID = GroupManager.getOpenGroupThreadID(group.id, this@JoinPublicChatActivity) + val groupID = GroupUtil.getEncodedOpenGroupID(group.id.toByteArray()) + threadID to groupID + } MultiDeviceProtocol.forceSyncConfigurationNowIfNeeded(this@JoinPublicChatActivity) + + withContext(Dispatchers.Main) { + // go to the new conversation and finish this one + openConversationActivity(this@JoinPublicChatActivity, threadID, Recipient.from(this@JoinPublicChatActivity, Address.fromSerialized(groupID), false)) + finish() + } + } catch (e: Exception) { Log.e("JoinPublicChatActivity", "Fialed to join open group.", e) withContext(Dispatchers.Main) { @@ -83,10 +128,19 @@ class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCode } return@launch } - withContext(Dispatchers.Main) { finish() } } } // endregion + + // region Convenience + private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { + val intent = Intent(context, ConversationActivity::class.java) + intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId) + intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, DistributionTypes.DEFAULT) + intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address) + context.startActivity(intent) + } + // endregion } // region Adapter @@ -109,7 +163,7 @@ private class JoinPublicChatActivityAdapter(val activity: JoinPublicChatActivity } } - override fun getPageTitle(index: Int): CharSequence? { + override fun getPageTitle(index: Int): CharSequence { return when (index) { 0 -> activity.resources.getString(R.string.activity_join_public_chat_enter_group_url_tab_title) 1 -> activity.resources.getString(R.string.activity_join_public_chat_scan_qr_code_tab_title) @@ -122,24 +176,63 @@ private class JoinPublicChatActivityAdapter(val activity: JoinPublicChatActivity // region Enter Chat URL Fragment class EnterChatURLFragment : Fragment() { + private val viewModel by activityViewModels() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return inflater.inflate(R.layout.fragment_enter_chat_url, container, false) } + private fun populateDefaultGroups(groups: List) { + defaultRoomsGridLayout.removeAllViews() + groups.forEach { defaultGroup -> + val chip = layoutInflater.inflate(R.layout.default_group_chip,defaultRoomsGridLayout, false) as Chip + val drawable = defaultGroup.image?.let { bytes -> + val bitmap = BitmapFactory.decodeByteArray(bytes,0,bytes.size) + RoundedBitmapDrawableFactory.create(resources,bitmap).apply { + isCircular = true + } + } + chip.chipIcon = drawable + chip.text = defaultGroup.name + chip.setOnClickListener { + (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(defaultGroup.toJoinUrl()) + } + defaultRoomsGridLayout.addView(chip) + } + if (groups.size and 1 != 0) { + // add a filler weight 1 view + layoutInflater.inflate(R.layout.grid_layout_filler, defaultRoomsGridLayout) + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) chatURLEditText.imeOptions = chatURLEditText.imeOptions or 16777216 // Always use incognito keyboard joinPublicChatButton.setOnClickListener { joinPublicChatIfPossible() } + viewModel.defaultRooms.observe(viewLifecycleOwner) { state -> + defaultRoomsParent.isVisible = state is State.Success + defaultRoomsLoader.isVisible = state is State.Loading + when (state) { + State.Loading -> { + // show a loader here probs + } + is State.Error -> { + // hide the loader and the + } + is State.Success -> { + populateDefaultGroups(state.value) + } + } + } } + // region Convenience private fun joinPublicChatIfPossible() { val inputMethodManager = requireContext().getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.hideSoftInputFromWindow(chatURLEditText.windowToken, 0) - var chatURL = chatURLEditText.text.trim().toString().toLowerCase().replace("http://", "https://") - if (!chatURL.toLowerCase().startsWith("https")) { - chatURL = "https://$chatURL" - } + val chatURL = chatURLEditText.text.trim().toString().toLowerCase() (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(chatURL) } + // endregion } // endregion \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/api/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/api/BackgroundPollWorker.kt index a39cbb0aca..7b4f2c2aa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/api/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/api/BackgroundPollWorker.kt @@ -9,8 +9,10 @@ import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.map import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupV2Poller import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.logging.Log @@ -90,6 +92,14 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor promises.add(poller.pollForNewMessages()) } + val openGroupsV2 = DatabaseFactory.getLokiThreadDatabase(context).getAllV2OpenGroups().values.groupBy(OpenGroupV2::server) + + openGroupsV2.values.map { groups -> + OpenGroupV2Poller(groups) + }.forEach { poller -> + promises.add(poller.compactPoll(true).map{ /*Unit*/ }) + } + // Wait till all the promises get resolved all(promises).get() diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatInfoUpdateWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatInfoUpdateWorker.kt index c8f1a1f215..2badd9c6d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatInfoUpdateWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/api/PublicChatInfoUpdateWorker.kt @@ -16,6 +16,25 @@ class PublicChatInfoUpdateWorker(val context: Context, params: WorkerParameters) private const val DATA_KEY_SERVER_URL = "server_uRL" private const val DATA_KEY_CHANNEL = "channel" + private const val DATA_KEY_ROOM = "room" + + @JvmStatic + fun scheduleInstant(context: Context, serverUrl: String, room :String) { + val workRequest = OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setInputData(workDataOf( + DATA_KEY_SERVER_URL to serverUrl, + DATA_KEY_ROOM to room + )) + .build() + + WorkManager + .getInstance(context) + .enqueue(workRequest) + } @JvmStatic fun scheduleInstant(context: Context, serverURL: String, channel: Long) { @@ -39,17 +58,35 @@ class PublicChatInfoUpdateWorker(val context: Context, params: WorkerParameters) override fun doWork(): Result { val serverUrl = inputData.getString(DATA_KEY_SERVER_URL)!! val channel = inputData.getLong(DATA_KEY_CHANNEL, -1) + val room = inputData.getString(DATA_KEY_ROOM) - val publicChatId = OpenGroup.getId(channel, serverUrl) + val isOpenGroupV2 = !room.isNullOrEmpty() && channel == -1L + + if (!isOpenGroupV2) { + val publicChatId = OpenGroup.getId(channel, serverUrl) + + return try { + Log.v(TAG, "Updating open group info for $publicChatId.") + OpenGroupUtilities.updateGroupInfo(context, serverUrl, channel) + Log.v(TAG, "Open group info was successfully updated for $publicChatId.") + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Failed to update open group info for $publicChatId", e) + Result.failure() + } + } else { + val openGroupId = "$serverUrl.$room" + + return try { + Log.v(TAG, "Updating open group info for $openGroupId.") + OpenGroupUtilities.updateGroupInfo(context, serverUrl, room!!) + Log.v(TAG, "Open group info was successfully updated for $openGroupId.") + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Failed to update open group info for $openGroupId", e) + Result.failure() + } - return try { - Log.v(TAG, "Updating open group info for $publicChatId.") - OpenGroupUtilities.updateGroupInfo(context, serverUrl, channel) - Log.v(TAG, "Open group info was successfully updated for $publicChatId.") - Result.success() - } catch (e: Exception) { - Log.e(TAG, "Failed to update open group info for $publicChatId", e) - Result.failure() } } } 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 6586c45013..04ed0e4c95 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 @@ -6,10 +6,9 @@ import android.graphics.Bitmap import android.text.TextUtils import androidx.annotation.WorkerThread import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.open_groups.OpenGroupAPI -import org.session.libsession.messaging.open_groups.OpenGroupInfo +import org.session.libsession.messaging.open_groups.* import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupV2Poller import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.Util import org.thoughtcrime.securesms.database.DatabaseContentProviders @@ -20,15 +19,17 @@ import java.util.concurrent.Executors class PublicChatManager(private val context: Context) { private var chats = mutableMapOf() + private var v2Chats = mutableMapOf() private val pollers = mutableMapOf() + private val v2Pollers = mutableMapOf() private val observers = mutableMapOf() private var isPolling = false - private val executorService = Executors.newScheduledThreadPool(16) + private val executorService = Executors.newScheduledThreadPool(4) public fun areAllCaughtUp(): Boolean { var areAllCaughtUp = true refreshChatsAndPollers() - for ((threadID, chat) in chats) { + for ((threadID, _) in chats) { val poller = pollers[threadID] areAllCaughtUp = if (poller != null) areAllCaughtUp && poller.isCaughtUp else true } @@ -52,6 +53,17 @@ class PublicChatManager(private val context: Context) { listenToThreadDeletion(threadId) if (!pollers.containsKey(threadId)) { pollers[threadId] = poller } } + v2Pollers.values.forEach { it.stop() } + v2Pollers.clear() + v2Chats.entries.groupBy { (_, group) -> group.server }.forEach { (server, threadedRooms) -> + val poller = OpenGroupV2Poller(threadedRooms.map { it.value }, executorService) + poller.startIfNeeded() + threadedRooms.forEach { (thread, _) -> + listenToThreadDeletion(thread) + } + v2Pollers[server] = poller + } + isPolling = true } @@ -98,6 +110,26 @@ class PublicChatManager(private val context: Context) { return chat } + @WorkerThread + fun addChat(server: String, room: String, info: OpenGroupAPIV2.Info, publicKey: String): OpenGroupV2 { + val chat = OpenGroupV2(server, room, info.name, publicKey) + var threadID = GroupManager.getOpenGroupThreadID(chat.id, context) + val profilePicture: Bitmap? + if (threadID < 0) { + val profilePictureAsByteArray = try { + OpenGroupAPIV2.downloadOpenGroupProfilePicture(info.id,server).get() + } catch (e: Exception) { + null + } + profilePicture = BitmapUtil.fromByteArray(profilePictureAsByteArray) + val result = GroupManager.createOpenGroup(chat.id, context, profilePicture, info.name) + threadID = result.threadId + } + DatabaseFactory.getLokiThreadDatabase(context).setOpenGroupChat(chat, threadID) + Util.runOnMain { startPollersIfNeeded() } + return chat + } + public fun removeChat(server: String, channel: Long) { val threadDB = DatabaseFactory.getThreadDatabase(context) val groupId = OpenGroup.getId(channel, server) @@ -108,14 +140,26 @@ class PublicChatManager(private val context: Context) { Util.runOnMain { startPollersIfNeeded() } } + fun removeChat(server: String, room: String) { + val threadDB = DatabaseFactory.getThreadDatabase(context) + val groupId = "$server.$room" + val threadId = GroupManager.getOpenGroupThreadID(groupId, context) + val groupAddress = threadDB.getRecipientForThreadId(threadId)!!.address.serialize() + GroupManager.deleteGroup(groupAddress, context) + + Util.runOnMain { startPollersIfNeeded() } + } + private fun refreshChatsAndPollers() { val storage = MessagingModuleConfiguration.shared.storage val chatsInDB = storage.getAllOpenGroups() + val v2ChatsInDB = storage.getAllV2OpenGroups() val removedChatThreadIds = chats.keys.filter { !chatsInDB.keys.contains(it) } removedChatThreadIds.forEach { pollers.remove(it)?.stop() } // Only append to chats if we have a thread for the chat chats = chatsInDB.filter { GroupManager.getOpenGroupThreadID(it.value.id, context) > -1 }.toMutableMap() + v2Chats = v2ChatsInDB.filter { GroupManager.getOpenGroupThreadID(it.value.id, context) > -1 }.toMutableMap() } private fun listenToThreadDeletion(threadID: Long) { @@ -132,6 +176,8 @@ class PublicChatManager(private val context: Context) { DatabaseFactory.getLokiThreadDatabase(context).removePublicChat(threadID) pollers.remove(threadID)?.stop() + v2Pollers.values.forEach { it.stop() } + v2Pollers.clear() observers.remove(threadID) startPollersIfNeeded() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt index 06da1f0676..53075bdae7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt @@ -286,6 +286,14 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( }?.toLong() } + override fun getLastMessageServerID(room: String, server: String): Long? { + val database = databaseHelper.writableDatabase + val index = "$server.$room" + return database.get(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) { cursor -> + cursor.getInt(lastMessageServerID) + }?.toLong() + } + override fun setLastMessageServerID(group: Long, server: String, newValue: Long) { val database = databaseHelper.writableDatabase val index = "$server.$group" @@ -293,12 +301,25 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( database.insertOrUpdate(lastMessageServerIDTable, row, "$lastMessageServerIDTableIndex = ?", wrap(index)) } + override fun setLastMessageServerID(room: String, server: String, newValue: Long) { + val database = databaseHelper.writableDatabase + val index = "$server.$room" + val row = wrap(mapOf( lastMessageServerIDTableIndex to index, lastMessageServerID to newValue.toString() )) + database.insertOrUpdate(lastMessageServerIDTable, row, "$lastMessageServerIDTableIndex = ?", wrap(index)) + } + fun removeLastMessageServerID(group: Long, server: String) { val database = databaseHelper.writableDatabase val index = "$server.$group" database.delete(lastMessageServerIDTable,"$lastMessageServerIDTableIndex = ?", wrap(index)) } + fun removeLastMessageServerID(room: String, server:String) { + val database = databaseHelper.writableDatabase + val index = "$server.$room" + database.delete(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) + } + override fun getLastDeletionServerID(group: Long, server: String): Long? { val database = databaseHelper.readableDatabase val index = "$server.$group" @@ -307,6 +328,14 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( }?.toLong() } + override fun getLastDeletionServerID(room: String, server: String): Long? { + val database = databaseHelper.readableDatabase + val index = "$server.$room" + return database.get(lastDeletionServerIDTable, "$lastDeletionServerIDTableIndex = ?", wrap(index)) { cursor -> + cursor.getInt(lastDeletionServerID) + }?.toLong() + } + override fun setLastDeletionServerID(group: Long, server: String, newValue: Long) { val database = databaseHelper.writableDatabase val index = "$server.$group" @@ -314,6 +343,19 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( database.insertOrUpdate(lastDeletionServerIDTable, row, "$lastDeletionServerIDTableIndex = ?", wrap(index)) } + override fun setLastDeletionServerID(room: String, server: String, newValue: Long) { + val database = databaseHelper.writableDatabase + val index = "$server.$room" + val row = wrap(mapOf(lastDeletionServerIDTableIndex to index, lastDeletionServerID to newValue.toString())) + database.insertOrUpdate(lastDeletionServerIDTable, row, "$lastDeletionServerIDTableIndex = ?", wrap(index)) + } + + fun removeLastDeletionServerID(room: String, server: String) { + val database = databaseHelper.writableDatabase + val index = "$server.$room" + database.delete(lastDeletionServerIDTable, "$lastDeletionServerIDTableIndex = ?", wrap(index)) + } + fun removeLastDeletionServerID(group: Long, server: String) { val database = databaseHelper.writableDatabase val index = "$server.$group" @@ -328,6 +370,14 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( }?.toInt() } + fun getUserCount(room: String, server: String): Int? { + val database = databaseHelper.readableDatabase + val index = "$server.$room" + return database.get(userCountTable, "$publicChatID = ?", wrap(index)) { cursor -> + cursor.getInt(userCount) + }?.toInt() + } + override fun setUserCount(group: Long, server: String, newValue: Int) { val database = databaseHelper.writableDatabase val index = "$server.$group" @@ -335,6 +385,13 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( database.insertOrUpdate(userCountTable, row, "$publicChatID = ?", wrap(index)) } + override fun setUserCount(room: String, server: String, newValue: Int) { + val database = databaseHelper.writableDatabase + val index = "$server.$room" + val row = wrap(mapOf( publicChatID to index, userCount to newValue.toString() )) + database.insertOrUpdate(userCountTable, row, "$publicChatID = ?", wrap(index)) + } + override fun getSessionRequestSentTimestamp(publicKey: String): Long? { val database = databaseHelper.readableDatabase return database.get(sessionRequestSentTimestampTable, "${LokiAPIDatabase.publicKey} = ?", wrap(publicKey)) { cursor -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiMessageDatabase.kt index 1da93f7ec3..85a66b159c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiMessageDatabase.kt @@ -5,10 +5,7 @@ import android.content.Context import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import org.thoughtcrime.securesms.loki.utilities.get -import org.thoughtcrime.securesms.loki.utilities.getInt -import org.thoughtcrime.securesms.loki.utilities.getString -import org.thoughtcrime.securesms.loki.utilities.insertOrUpdate +import org.thoughtcrime.securesms.loki.utilities.* import org.session.libsignal.service.loki.LokiMessageDatabaseProtocol class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { @@ -22,56 +19,111 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab private val friendRequestStatus = "friend_request_status" private val threadID = "thread_id" private val errorMessage = "error_message" - @JvmStatic val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);" - @JvmStatic val createMessageToThreadMappingTableCommand = "CREATE TABLE IF NOT EXISTS $messageThreadMappingTable ($messageID INTEGER PRIMARY KEY, $threadID INTEGER);" - @JvmStatic val createErrorMessageTableCommand = "CREATE TABLE IF NOT EXISTS $errorMessageTable ($messageID INTEGER PRIMARY KEY, $errorMessage STRING);" + private val messageType = "message_type" + @JvmStatic + val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);" + @JvmStatic + val createMessageToThreadMappingTableCommand = "CREATE TABLE IF NOT EXISTS $messageThreadMappingTable ($messageID INTEGER PRIMARY KEY, $threadID INTEGER);" + @JvmStatic + val createErrorMessageTableCommand = "CREATE TABLE IF NOT EXISTS $errorMessageTable ($messageID INTEGER PRIMARY KEY, $errorMessage STRING);" + @JvmStatic + val updateMessageIDTableForType = "ALTER TABLE $messageIDTable ADD COLUMN $messageType INTEGER DEFAULT 0; ALTER TABLE $messageIDTable ADD CONSTRAINT PK_$messageIDTable PRIMARY KEY ($messageID, $serverID);" + @JvmStatic + val updateMessageMappingTable = "ALTER TABLE $messageThreadMappingTable ADD COLUMN $serverID INTEGER DEFAULT 0; ALTER TABLE $messageThreadMappingTable ADD CONSTRAINT PK_$messageThreadMappingTable PRIMARY KEY ($messageID, $serverID);" + + const val SMS_TYPE = 0 + const val MMS_TYPE = 1 + } override fun getQuoteServerID(quoteID: Long, quoteePublicKey: String): Long? { val message = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(quoteID, quoteePublicKey) - return if (message != null) getServerID(message.getId()) else null + return if (message != null) getServerID(message.getId(), !message.isMms) else null } fun getServerID(messageID: Long): Long? { val database = databaseHelper.readableDatabase - return database.get(messageIDTable, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> + return database.get(messageIDTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> + cursor.getInt(serverID) + }?.toLong() + } + + fun getServerID(messageID: Long, isSms: Boolean): Long? { + val database = databaseHelper.readableDatabase + return database.get(messageIDTable, "${Companion.messageID} = ? AND $messageType = ?", arrayOf(messageID.toString(), if (isSms) SMS_TYPE.toString() else MMS_TYPE.toString())) { cursor -> cursor.getInt(serverID) }?.toLong() } fun getMessageID(serverID: Long): Long? { val database = databaseHelper.readableDatabase - return database.get(messageIDTable, "${Companion.serverID} = ?", arrayOf( serverID.toString() )) { cursor -> + return database.get(messageIDTable, "${Companion.serverID} = ?", arrayOf(serverID.toString())) { cursor -> cursor.getInt(messageID) }?.toLong() } - override fun setServerID(messageID: Long, serverID: Long) { + fun deleteMessage(messageID: Long, isSms: Boolean) { + val database = databaseHelper.writableDatabase + + val serverID = database.get(messageIDTable, + "${Companion.messageID} = ? AND ${Companion.messageType} = ?", + arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor -> + cursor.getInt(Companion.serverID).toLong() + } ?: return + + database.beginTransaction() + + database.delete(messageIDTable, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.toString(), serverID.toString())) + database.delete(messageThreadMappingTable, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.toString(), serverID.toString())) + + database.setTransactionSuccessful() + database.endTransaction() + } + + fun getMessageID(serverID: Long, threadID: Long): Pair? { + val database = databaseHelper.readableDatabase + val mappingResult = database.get(messageThreadMappingTable, "${Companion.serverID} = ? AND ${Companion.threadID} = ?", + arrayOf(serverID.toString(), threadID.toString())) { cursor -> + cursor.getInt(messageID) to cursor.getInt(Companion.serverID) + } ?: return null + + val (mappedID, mappedServerID) = mappingResult + + return database.get(messageIDTable, + "$messageID = ? AND ${Companion.serverID} = ?", + arrayOf(mappedID.toString(), mappedServerID.toString())) { cursor -> + cursor.getInt(Companion.messageID).toLong() to (cursor.getInt(messageType) == SMS_TYPE) + } + } + + override fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) { val database = databaseHelper.writableDatabase val contentValues = ContentValues(2) contentValues.put(Companion.messageID, messageID) contentValues.put(Companion.serverID, serverID) - database.insertOrUpdate(messageIDTable, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) + contentValues.put(messageType, if (isSms) SMS_TYPE else MMS_TYPE) + database.insertOrUpdate(messageIDTable, contentValues, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.toString(), serverID.toString())) } fun getOriginalThreadID(messageID: Long): Long { val database = databaseHelper.readableDatabase - return database.get(messageThreadMappingTable, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> + return database.get(messageThreadMappingTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> cursor.getInt(threadID) }?.toLong() ?: -1L } - fun setOriginalThreadID(messageID: Long, threadID: Long) { + fun setOriginalThreadID(messageID: Long, serverID: Long, threadID: Long) { val database = databaseHelper.writableDatabase val contentValues = ContentValues(2) contentValues.put(Companion.messageID, messageID) + contentValues.put(Companion.serverID, serverID) contentValues.put(Companion.threadID, threadID) - database.insertOrUpdate(messageThreadMappingTable, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) + database.insertOrUpdate(messageThreadMappingTable, contentValues, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.toString(), serverID.toString())) } fun getErrorMessage(messageID: Long): String? { val database = databaseHelper.readableDatabase - return database.get(errorMessageTable, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> + return database.get(errorMessageTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> cursor.getString(errorMessage) } } @@ -81,6 +133,6 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val contentValues = ContentValues(2) contentValues.put(Companion.messageID, messageID) contentValues.put(Companion.errorMessage, errorMessage) - database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) + database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiThreadDatabase.kt index 3f13eef929..ba9c0ff477 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiThreadDatabase.kt @@ -10,12 +10,11 @@ import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.loki.utilities.* +import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.messaging.threads.Address import org.session.libsession.messaging.threads.recipients.Recipient -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.service.loki.utilities.PublicKeyValidation class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -26,8 +25,10 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa private val friendRequestStatus = "friend_request_status" private val sessionResetStatus = "session_reset_status" val publicChat = "public_chat" - @JvmStatic val createSessionResetTableCommand = "CREATE TABLE $sessionResetTable ($threadID INTEGER PRIMARY KEY, $sessionResetStatus INTEGER DEFAULT 0);" - @JvmStatic val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);" + @JvmStatic + val createSessionResetTableCommand = "CREATE TABLE $sessionResetTable ($threadID INTEGER PRIMARY KEY, $sessionResetStatus INTEGER DEFAULT 0);" + @JvmStatic + val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);" } fun getThreadID(hexEncodedPublicKey: String): Long { @@ -46,11 +47,33 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa val threadID = cursor.getLong(threadID) val string = cursor.getString(publicChat) val publicChat = OpenGroup.fromJSON(string) - if (publicChat != null) { result[threadID] = publicChat } + if (publicChat != null) { + result[threadID] = publicChat + } } } catch (e: Exception) { // Do nothing - } finally { + } finally { + cursor?.close() + } + return result + } + + fun getAllV2OpenGroups(): Map { + val database = databaseHelper.readableDatabase + var cursor: Cursor? = null + val result = mutableMapOf() + try { + cursor = database.rawQuery("select * from $publicChatTable", null) + while (cursor != null && cursor.moveToNext()) { + val threadID = cursor.getLong(threadID) + val string = cursor.getString(publicChat) + val openGroup = OpenGroupV2.fromJson(string) + if (openGroup != null) result[threadID] = openGroup + } + } catch (e: Exception) { + // do nothing + } finally { cursor?.close() } return result @@ -62,23 +85,48 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa fun getPublicChat(threadID: Long): OpenGroup? { if (threadID < 0) { return null } + val database = databaseHelper.readableDatabase - return database.get(publicChatTable, "${Companion.threadID} = ?", arrayOf( threadID.toString() )) { cursor -> + return database.get(publicChatTable, "${Companion.threadID} = ?", arrayOf(threadID.toString())) { cursor -> val publicChatAsJSON = cursor.getString(publicChat) OpenGroup.fromJSON(publicChatAsJSON) } } + fun getOpenGroupChat(threadID: Long): OpenGroupV2? { + if (threadID < 0) { + return null + } + val database = databaseHelper.readableDatabase + return database.get(publicChatTable, "${Companion.threadID} = ?", arrayOf(threadID.toString())) { cursor -> + val json = cursor.getString(publicChat) + OpenGroupV2.fromJson(json) + } + } + + fun setOpenGroupChat(openGroupV2: OpenGroupV2, threadID: Long) { + if (threadID < 0) { + return + } + val database = databaseHelper.writableDatabase + val contentValues = ContentValues(2) + contentValues.put(Companion.threadID, threadID) + contentValues.put(publicChat, JsonUtil.toJson(openGroupV2.toJson())) + database.insertOrUpdate(publicChatTable, contentValues, "${Companion.threadID} = ?", arrayOf(threadID.toString())) + } + fun setPublicChat(publicChat: OpenGroup, threadID: Long) { - if (threadID < 0) { return } + if (threadID < 0) { + return + } val database = databaseHelper.writableDatabase val contentValues = ContentValues(2) contentValues.put(Companion.threadID, threadID) contentValues.put(Companion.publicChat, JsonUtil.toJson(publicChat.toJSON())) - database.insertOrUpdate(publicChatTable, contentValues, "${Companion.threadID} = ?", arrayOf( threadID.toString() )) + database.insertOrUpdate(publicChatTable, contentValues, "${Companion.threadID} = ?", arrayOf(threadID.toString())) } fun removePublicChat(threadID: Long) { - databaseHelper.writableDatabase.delete(publicChatTable, "${Companion.threadID} = ?", arrayOf( threadID.toString() )) + databaseHelper.writableDatabase.delete(publicChatTable, "${Companion.threadID} = ?", arrayOf(threadID.toString())) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionManagerUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionManagerUtilities.kt index 7d5503c8fc..faef8f0cc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionManagerUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionManagerUtilities.kt @@ -1,10 +1,10 @@ package org.thoughtcrime.securesms.loki.utilities import android.content.Context +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.messaging.mentions.MentionsManager import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.model.MessageRecord -import org.session.libsession.utilities.TextSecurePreferences object MentionManagerUtilities { 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 8a5f8f6bd0..cf8c747638 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,20 +3,47 @@ package org.thoughtcrime.securesms.loki.utilities import android.content.Context import androidx.annotation.WorkerThread import org.greenrobot.eventbus.EventBus +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupAPI +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.threads.recipients.Recipient import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.preferences.ProfileKeyUtil import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.groups.GroupManager +import java.util.* //TODO Refactor so methods declare specific type of checked exceptions and not generalized Exception. object OpenGroupUtilities { private const val TAG = "OpenGroupUtilities" + @JvmStatic + @WorkerThread + fun addGroup(context: Context, server: String, room: String, publicKey: String): OpenGroupV2 { + val groupId = "$server.$room" + val threadID = GroupManager.getOpenGroupThreadID(groupId, context) + val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID) + if (openGroup != null) return openGroup + + MessagingModuleConfiguration.shared.storage.setOpenGroupPublicKey(server,publicKey) + OpenGroupAPIV2.getAuthToken(room, server).get() + val groupInfo = OpenGroupAPIV2.getInfo(room,server).get() + val application = ApplicationContext.getInstance(context) + + val group = application.publicChatManager.addChat(server, room, groupInfo, publicKey) + + val storage = MessagingModuleConfiguration.shared.storage + storage.removeLastDeletionServerId(room, server) + storage.removeLastMessageServerId(room, server) + + return group + } + @JvmStatic @WorkerThread @Throws(Exception::class) @@ -67,5 +94,30 @@ object OpenGroupUtilities { EventBus.getDefault().post(GroupInfoUpdatedEvent(url, channel)) } - data class GroupInfoUpdatedEvent(val url: String, val channel: Long) + @JvmStatic + @WorkerThread + @Throws(Exception::class) + fun updateGroupInfo(context: Context, server: String, room: String) { + val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) + if (!DatabaseFactory.getGroupDatabase(context).hasGroup(groupId)) { + 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() + + EventBus.getDefault().post(GroupInfoUpdatedEvent(server, room = room)) + } + + /** + * Return a generated name for users in the style of `$name (...$hex.takeLast(8))` for public groups + */ + @JvmStatic + fun getDisplayName(recipient: Recipient): String { + return String.format(Locale.ROOT, PUBLIC_GROUP_STRING_FORMAT, recipient.name, recipient.address.serialize().takeLast(8)) + } + + const val PUBLIC_GROUP_STRING_FORMAT = "%s (...%s)" + + data class GroupInfoUpdatedEvent(val url: String, val channel: Long = -1, val room: String = "") } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/viewmodel/DefaultGroupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/viewmodel/DefaultGroupsViewModel.kt new file mode 100644 index 0000000000..a2d747ed9b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/viewmodel/DefaultGroupsViewModel.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.loki.viewmodel + +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 + +typealias DefaultGroups = List +typealias GroupState = State + +class DefaultGroupsViewModel : ViewModel() { + + init { + OpenGroupAPIV2.getDefaultRoomsIfNeeded() + } + + val defaultRooms = OpenGroupAPIV2.defaultRooms.map { + State.Success(it) + }.onStart { + emit(State.Loading) + }.asLiveData() + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/viewmodel/State.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/viewmodel/State.kt new file mode 100644 index 0000000000..94227d0e0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/viewmodel/State.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.loki.viewmodel + +sealed class State { + object Loading : State() + data class Success(val value: T): State() + data class Error(val error: Exception): State() +} diff --git a/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml b/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml index a1bf13aedc..a4b088aac1 100644 --- a/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml +++ b/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml @@ -1,6 +1,5 @@ - + android:hint="@string/fragment_enter_chat_url_edit_text_hint" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_enter_chat_url.xml b/app/src/main/res/layout/fragment_enter_chat_url.xml index b778d99b38..7affed157e 100644 --- a/app/src/main/res/layout/fragment_enter_chat_url.xml +++ b/app/src/main/res/layout/fragment_enter_chat_url.xml @@ -1,6 +1,5 @@ - + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 697b85ff20..83997f2bdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1898,5 +1898,6 @@ 30-digit passphrase This is taking a while, would you like to skip? + Or join one of these... diff --git a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt index 1584f41de4..b34a4ac3be 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -11,7 +11,8 @@ import java.io.InputStream interface MessageDataProvider { fun getMessageID(serverID: Long): Long? - fun deleteMessage(messageID: Long) + fun getMessageID(serverId: Long, threadId: Long): Pair? + fun deleteMessage(messageID: Long, isSms: Boolean) fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? diff --git a/libsession/src/main/java/org/session/libsession/messaging/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/messaging/StorageProtocol.kt index 5bca1cee6b..906153d2c6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/StorageProtocol.kt @@ -10,6 +10,7 @@ import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupV2 import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment @@ -52,16 +53,18 @@ interface StorageProtocol { fun isJobCanceled(job: Job): Boolean // Authorization - fun getAuthToken(server: String): String? - fun setAuthToken(server: String, newValue: String?) - fun removeAuthToken(server: String) + fun getAuthToken(room: String, server: String): String? + fun setAuthToken(room: String, server: String, newValue: String) + fun removeAuthToken(room: String, server: String) + + // Open Groups + fun getAllV2OpenGroups(): Map + fun getV2OpenGroup(threadId: String): OpenGroupV2? // Open Groups - fun getOpenGroup(threadID: String): OpenGroup? fun getThreadID(openGroupID: String): String? - fun getAllOpenGroups(): Map fun addOpenGroup(server: String, channel: Long) - fun setOpenGroupServerMessageID(messageID: Long, serverID: Long) + fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) fun getQuoteServerID(quoteID: Long, publicKey: String): Long? // Open Group Public Keys @@ -69,25 +72,24 @@ interface StorageProtocol { fun setOpenGroupPublicKey(server: String, newValue: String) // Open Group User Info - fun setOpenGroupDisplayName(publicKey: String, channel: Long, server: String, displayName: String) - fun getOpenGroupDisplayName(publicKey: String, channel: Long, server: String): String? + fun setOpenGroupDisplayName(publicKey: String, room: String, server: String, displayName: String) + fun getOpenGroupDisplayName(publicKey: String, room: String, server: String): String? // Open Group Metadata - fun setUserCount(group: Long, server: String, newValue: Int) - fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String) - fun getOpenGroupProfilePictureURL(group: Long, server: String): String? + fun updateTitle(groupID: String, newValue: String) fun updateProfilePicture(groupID: String, newValue: ByteArray) + fun setUserCount(room: String, server: String, newValue: Int) // Last Message Server ID - fun getLastMessageServerID(group: Long, server: String): Long? - fun setLastMessageServerID(group: Long, server: String, newValue: Long) - fun removeLastMessageServerID(group: Long, server: String) + fun getLastMessageServerId(room: String, server: String): Long? + fun setLastMessageServerId(room: String, server: String, newValue: Long) + fun removeLastMessageServerId(room: String, server: String) // Last Deletion Server ID - fun getLastDeletionServerID(group: Long, server: String): Long? - fun setLastDeletionServerID(group: Long, server: String, newValue: Long) - fun removeLastDeletionServerID(group: Long, server: String) + fun getLastDeletionServerId(room: String, server: String): Long? + fun setLastDeletionServerId(room: String, server: String, newValue: Long) + fun removeLastDeletionServerId(room: String, server: String) // Message Handling fun isMessageDuplicated(timestamp: Long, sender: String): Boolean @@ -137,6 +139,7 @@ interface StorageProtocol { fun getOrCreateThreadIdFor(address: Address): Long fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long fun getThreadIdFor(address: Address): Long? + fun getThreadIdForMms(mmsId: Long): Long // Session Request fun getSessionRequestSentTimestamp(publicKey: String): Long? @@ -164,4 +167,28 @@ interface StorageProtocol { // Data Extraction Notification fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) + + // DEPRECATED + fun getAuthToken(server: String): String? + fun setAuthToken(server: String, newValue: String?) + fun removeAuthToken(server: String) + + fun getLastMessageServerID(group: Long, server: String): Long? + fun setLastMessageServerID(group: Long, server: String, newValue: Long) + fun removeLastMessageServerID(group: Long, server: String) + + fun getLastDeletionServerID(group: Long, server: String): Long? + fun setLastDeletionServerID(group: Long, server: String, newValue: Long) + fun removeLastDeletionServerID(group: Long, server: String) + + fun getOpenGroup(threadID: String): OpenGroup? + fun getAllOpenGroups(): Map + + fun setUserCount(group: Long, server: String, newValue: Int) + fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String) + fun getOpenGroupProfilePictureURL(group: Long, server: String): String? + + fun setOpenGroupDisplayName(publicKey: String, channel: Long, server: String, displayName: String) + fun getOpenGroupDisplayName(publicKey: String, channel: Long, server: String): String? + } 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 557d161667..3298f9d3dc 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 @@ -1,7 +1,9 @@ package org.session.libsession.messaging.jobs +import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.file_server.FileServerAPI +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.utilities.DotNetAPI import org.session.libsignal.service.api.crypto.AttachmentCipherInputStream @@ -10,7 +12,7 @@ import org.session.libsignal.utilities.logging.Log import java.io.File import java.io.FileInputStream -class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long): Job { +class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) : Job { override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 @@ -22,6 +24,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) // Settings override val maxFailureCount: Int = 20 + companion object { val KEY: String = "AttachmentDownloadJob" @@ -46,18 +49,31 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } try { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider - val attachment = messageDataProvider.getDatabaseAttachment(attachmentID) ?: return handleFailure(Error.NoAttachment) + val attachment = messageDataProvider.getDatabaseAttachment(attachmentID) + ?: return handleFailure(Error.NoAttachment) messageDataProvider.setAttachmentState(AttachmentState.STARTED, attachmentID, this.databaseMessageID) val tempFile = createTempFile() - FileServerAPI.shared.downloadFile(tempFile, attachment.url, null) + val threadId = MessagingModuleConfiguration.shared.storage.getThreadIdForMms(databaseMessageID) + val openGroupV2 = MessagingModuleConfiguration.shared.storage.getV2OpenGroup(threadId.toString()) - // Assume we're retrieving an attachment for an open group server if the digest is not set - val stream = if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) FileInputStream(tempFile) - else AttachmentCipherInputStream.createForAttachment(tempFile, attachment.size, Base64.decode(attachment.key), attachment.digest) + val stream = if (openGroupV2 == null) { + FileServerAPI.shared.downloadFile(tempFile, attachment.url, null) + + + // Assume we're retrieving an attachment for an open group server if the digest is not set + if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) FileInputStream(tempFile) + else AttachmentCipherInputStream.createForAttachment(tempFile, attachment.size, Base64.decode(attachment.key), attachment.digest) + } else { + val url = HttpUrl.parse(attachment.url)!! + val fileId = url.pathSegments().last() + OpenGroupAPIV2.download(fileId.toLong(), openGroupV2.room, openGroupV2.server).get().let { + tempFile.writeBytes(it) + } + FileInputStream(tempFile) + } messageDataProvider.insertAttachment(databaseMessageID, attachment.attachmentId, stream) - tempFile.delete() handleSuccess() } catch (e: Exception) { @@ -94,8 +110,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) return KEY } - class Factory: Job.Factory { - + class Factory : Job.Factory { override fun create(data: Data): AttachmentDownloadJob { return AttachmentDownloadJob(data.getLong(KEY_ATTACHMENT_ID), data.getLong(KEY_TS_INCOMING_MESSAGE_ID)) } 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 2d16b7b9b9..f135178618 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 @@ -6,6 +6,7 @@ import com.esotericsoftware.kryo.io.Output import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.file_server.FileServerAPI import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.DotNetAPI import org.session.libsignal.service.api.crypto.AttachmentCipherOutputStream @@ -46,9 +47,14 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess ?: return handleFailure(Error.NoAttachment) val usePadding = false + val openGroupV2 = MessagingModuleConfiguration.shared.storage.getV2OpenGroup(threadID) val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(threadID) - val server = if (openGroup != null) openGroup.server else FileServerAPI.shared.server - val shouldEncrypt = (openGroup == null) // Encrypt if this isn't an open group + val server = openGroup?.let { + it.server + } ?: openGroupV2?.let { + it.server + } ?: FileServerAPI.shared.server + val shouldEncrypt = (openGroup == null && openGroupV2 == null) // Encrypt if this isn't an open group val attachmentKey = Util.getSecretBytes(64) val paddedLength = if (usePadding) PaddingInputStream.getPaddedSize(attachment.length) else attachment.length @@ -58,7 +64,11 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess val outputStreamFactory = if (shouldEncrypt) AttachmentCipherOutputStreamFactory(attachmentKey) else PlaintextOutputStreamFactory() val attachmentData = PushAttachmentData(attachment.contentType, dataStream, ciphertextLength, outputStreamFactory, attachment.listener) - val uploadResult = FileServerAPI.shared.uploadAttachment(server, attachmentData) + val uploadResult = if (openGroupV2 == null) FileServerAPI.shared.uploadAttachment(server, attachmentData) else { + val dataBytes = attachmentData.data.readBytes() + val result = OpenGroupAPIV2.upload(dataBytes, openGroupV2.room, openGroupV2.server).get() + DotNetAPI.UploadResult(result, "${openGroupV2.server}/files/$result", byteArrayOf()) + } handleSuccess(attachment, attachmentKey, uploadResult) } catch (e: java.lang.Exception) { if (e == Error.NoAttachment) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index ae9e5d4b35..9b1f65f4a7 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -18,6 +18,7 @@ class JobQueue : JobDelegate { private var hasResumedPendingJobs = false // Just for debugging private val jobTimestampMap = ConcurrentHashMap() private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val multiDispatcher = Executors.newFixedThreadPool(2).asCoroutineDispatcher() private val scope = GlobalScope + SupervisorJob() private val queue = Channel(UNLIMITED) @@ -28,8 +29,15 @@ class JobQueue : JobDelegate { scope.launch(dispatcher) { while (isActive) { queue.receive().let { job -> - job.delegate = this@JobQueue - job.execute() + if (job.canExecuteParallel()) { + launch(multiDispatcher) { + job.delegate = this@JobQueue + job.execute() + } + } else { + job.delegate = this@JobQueue + job.execute() + } } } } @@ -40,6 +48,13 @@ class JobQueue : JobDelegate { val shared: JobQueue by lazy { JobQueue() } } + private fun Job.canExecuteParallel(): Boolean { + return this.javaClass in arrayOf( + AttachmentUploadJob::class.java, + AttachmentDownloadJob::class.java + ) + } + fun add(job: Job) { addWithoutExecuting(job) queue.offer(job) // offer always called on unlimited capacity diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt index 250479ec93..4a2d99da84 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -1,10 +1,15 @@ package org.session.libsession.messaging.messages import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.threads.Address import org.session.libsession.utilities.GroupUtil import org.session.libsignal.service.loki.utilities.toHexString +typealias OpenGroupModel = OpenGroup +typealias OpenGroupV2Model = OpenGroupV2 + sealed class Destination { class Contact(var publicKey: String) : Destination() { @@ -16,6 +21,9 @@ sealed class Destination { class OpenGroup(var channel: Long, var server: String) : Destination() { internal constructor(): this(0, "") } + class OpenGroupV2(var room: String, var server: String): Destination() { + internal constructor(): this("", "") + } companion object { fun from(address: Address): Destination { @@ -29,9 +37,13 @@ sealed class Destination { ClosedGroup(groupPublicKey) } address.isOpenGroup -> { - val threadID = MessagingModuleConfiguration.shared.storage.getThreadID(address.contactIdentifier())!! - val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(threadID)!! - OpenGroup(openGroup.channel, openGroup.server) + val storage = MessagingModuleConfiguration.shared.storage + val threadID = storage.getThreadID(address.contactIdentifier())!! + when (val openGroup = storage.getOpenGroup(threadID) ?: storage.getV2OpenGroup(threadID)) { + is OpenGroupModel -> OpenGroup(openGroup.channel, openGroup.server) + is OpenGroupV2Model -> OpenGroupV2(openGroup.room, openGroup.server) + else -> throw Exception("Invalid OpenGroup $openGroup") + } } else -> { throw Exception("TODO: Handle legacy closed groups.") diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt new file mode 100644 index 0000000000..3dab0e9b9f --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt @@ -0,0 +1,505 @@ +package org.session.libsession.messaging.open_groups + +import com.fasterxml.jackson.databind.PropertyNamingStrategy +import com.fasterxml.jackson.databind.annotation.JsonNaming +import com.fasterxml.jackson.databind.type.TypeFactory +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import nl.komponents.kovenant.Kovenant +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.open_groups.OpenGroupAPIV2.Error +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.AESGCM +import org.session.libsignal.service.loki.HTTP +import org.session.libsignal.service.loki.HTTP.Verb.* +import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded +import org.session.libsignal.service.loki.utilities.toHexString +import org.session.libsignal.utilities.Base64.* +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.logging.Log +import org.whispersystems.curve25519.Curve25519 +import java.util.* + +object OpenGroupAPIV2 { + + private val moderators: HashMap> = hashMapOf() // Server URL to (channel ID to set of moderator IDs) + const val DEFAULT_SERVER = "http://116.203.70.33" + private const val DEFAULT_SERVER_PUBLIC_KEY = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" + + val defaultRooms = MutableSharedFlow>(replay = 1) + + private val curve = Curve25519.getInstance(Curve25519.BEST) + + sealed class Error : Exception() { + object GENERIC : Error() + object PARSING_FAILED : Error() + object DECRYPTION_FAILED : Error() + object SIGNING_FAILED : Error() + object INVALID_URL : Error() + object NO_PUBLIC_KEY : Error() + } + + data class DefaultGroup(val id: String, + val name: String, + val image: ByteArray?) { + fun toJoinUrl(): String = "$DEFAULT_SERVER/$id?public_key=$DEFAULT_SERVER_PUBLIC_KEY" + } + + data class Info( + val id: String, + val name: String, + val imageID: String? + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class CompactPollRequest(val roomId: String, + val authToken: String, + val fromDeletionServerId: Long?, + val fromMessageServerId: Long? + ) + + data class CompactPollResult(val messages: List, + val deletions: List, + val moderators: List + ) + + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + data class MessageDeletion @JvmOverloads constructor(val id: Long = 0, + val deletedMessageId: 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 + ) + + 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, isJsonRequired: Boolean = true): Promise, Exception> { + val parsed = HttpUrl.parse(request.server) ?: return Promise.ofFail(Error.INVALID_URL) + val urlBuilder = HttpUrl.Builder() + .scheme(parsed.scheme()) + .host(parsed.host()) + .port(parsed.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 requestBuilder = okhttp3.Request.Builder() + .url(urlBuilder.build()) + .headers(Headers.of(request.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) { + val publicKey = MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) + ?: return Promise.ofFail(Error.NO_PUBLIC_KEY) + return OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey, isJSONRequired = isJsonRequired) + .fail { e -> + if (e is OnionRequestAPI.HTTPRequestFailedAtDestinationException && e.statusCode == 401) { + val storage = MessagingModuleConfiguration.shared.storage + if (request.room != null) { + storage.removeAuthToken("${request.server}.${request.room}") + } else { + storage.removeAuthToken(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.PARSING_FAILED + decode(result) + } + } + + 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.getUserKeyPair() + ?: return Promise.ofFail(Error.GENERIC) + val queryParameters = mutableMapOf("public_key" to publicKey) + 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.PARSING_FAILED + val base64EncodedCiphertext = challenge["ciphertext"] as? String + ?: throw Error.PARSING_FAILED + val base64EncodedEphemeralPublicKey = challenge["ephemeral_public_key"] as? String + ?: throw Error.PARSING_FAILED + 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.DECRYPTION_FAILED + } + 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 } + } + + fun deleteAuthToken(room: String, server: String): Promise { + val request = Request(verb = DELETE, room = room, server = server, endpoint = "auth_token") + return send(request).map { + MessagingModuleConfiguration.shared.storage.removeAuthToken(room, server) + } + } + + // region Sending + 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? Long ?: throw Error.PARSING_FAILED + } + } + + 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.PARSING_FAILED + decode(base64EncodedFile) ?: throw Error.PARSING_FAILED + } + } + + fun send(message: OpenGroupMessageV2, room: String, server: String): Promise { + val signedMessage = message.sign() ?: return Promise.ofFail(Error.SIGNING_FAILED) + 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.PARSING_FAILED + OpenGroupMessageV2.fromJSON(rawMessage) ?: throw Error.PARSING_FAILED + } + } + // 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 { jsonList -> + @Suppress("UNCHECKED_CAST") val rawMessages = jsonList["messages"] as? List> + ?: throw Error.PARSING_FAILED + val lastMessageServerId = storage.getLastMessageServerId(room, server) ?: 0 + + var currentMax = lastMessageServerId + val messages = rawMessages.mapNotNull { json -> + 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 + } + if (message.serverID > lastMessageServerId) { + currentMax = message.serverID + } + message + } catch (e: Exception) { + null + } + } + storage.setLastMessageServerId(room, server, currentMax) + 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", "Deleted server message") + } + } + + 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.PARSING_FAILED + 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.PARSING_FAILED + 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 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 getCompactPoll(rooms: List, server: String): Promise, Exception> { + val requestAuths = rooms.associateWith { room -> getAuthToken(room, server) } + val storage = MessagingModuleConfiguration.shared.storage + val requests = rooms.mapNotNull { room -> + val authToken = try { + requestAuths[room]?.get() + } catch (e: Exception) { + Log.e("Loki", "Failed to get auth token for $room", e) + null + } ?: return@mapNotNull null + + CompactPollRequest(roomId = room, + authToken = authToken, + fromDeletionServerId = storage.getLastDeletionServerId(room, server), + fromMessageServerId = storage.getLastMessageServerId(room, server) + ) + } + val request = Request(verb = POST, room = null, server = server, endpoint = "compact_poll", isAuthRequired = false, parameters = mapOf("requests" to requests)) + // build a request for all rooms + return send(request = request).map { json -> + val results = json["results"] as? List<*> ?: throw Error.PARSING_FAILED + + results.mapNotNull { roomJson -> + if (roomJson !is Map<*,*>) return@mapNotNull null + val roomId = roomJson["room_id"] as? String ?: return@mapNotNull null + + // check the status was fine + val statusCode = roomJson["status_code"] as? Int ?: return@mapNotNull null + if (statusCode == 401) { + // delete auth token and return null + storage.removeAuthToken(roomId, server) + } + + // check and store mods + val moderators = roomJson["moderators"] as? List ?: return@mapNotNull null + handleModerators("$server.$roomId", moderators) + + // get deletions + val type = TypeFactory.defaultInstance().constructCollectionType(List::class.java, MessageDeletion::class.java) + val idsAsString = JsonUtil.toJson(roomJson["deletions"]) + val deletedServerIDs = JsonUtil.fromJson>(idsAsString, type) ?: throw Error.PARSING_FAILED + val lastDeletionServerId = storage.getLastDeletionServerId(roomId, server) ?: 0 + val serverID = deletedServerIDs.maxByOrNull {it.id } ?: MessageDeletion.EMPTY + if (serverID.id > lastDeletionServerId) { + storage.setLastDeletionServerId(roomId, server, serverID.id) + } + + // get messages + val rawMessages = roomJson["messages"] as? List> ?: return@mapNotNull null // parsing failed + + val lastMessageServerId = storage.getLastMessageServerId(roomId, server) ?: 0 + var currentMax = lastMessageServerId + val messages = rawMessages.mapNotNull { rawMessage -> + val message = OpenGroupMessageV2.fromJSON(rawMessage)?.apply { + currentMax = maxOf(currentMax,this.serverID ?: 0) + } + message + } + storage.setLastMessageServerId(roomId, server, currentMax) + roomId to CompactPollResult( + messages = messages, + deletions = deletedServerIDs.map { it.deletedMessageId }, + moderators = moderators + ) + }.toMap() + } + } + + fun getDefaultRoomsIfNeeded(): Promise, Exception> { + val storage = MessagingModuleConfiguration.shared.storage + storage.setOpenGroupPublicKey(DEFAULT_SERVER, DEFAULT_SERVER_PUBLIC_KEY) + return getAllRooms(DEFAULT_SERVER).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 with early non-image results + defaultRooms.replayCache.firstOrNull()?.let { replayed -> + if (replayed.none { it.image?.isNotEmpty() == true}) { + defaultRooms.tryEmit(earlyGroups) + } + } + val images = groups.map { group -> + group.id to downloadOpenGroupProfilePicture(group.id, DEFAULT_SERVER) + }.toMap() + + 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.PARSING_FAILED + val id = rawRoom["id"] as? String ?: throw Error.PARSING_FAILED + val name = rawRoom["name"] as? String ?: throw Error.PARSING_FAILED + 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.PARSING_FAILED + 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 = "member_count") + return send(request).map { json -> + val memberCount = json["member_count"] as? Int ?: throw Error.PARSING_FAILED + val storage = MessagingModuleConfiguration.shared.storage + storage.setUserCount(room, server, memberCount) + memberCount + } + } + // endregion + +} + +fun Error.errorDescription() = when (this) { + Error.GENERIC -> "An error occurred." + Error.PARSING_FAILED -> "Invalid response." + Error.DECRYPTION_FAILED -> "Couldn't decrypt response." + Error.SIGNING_FAILED -> "Couldn't sign message." + Error.INVALID_URL -> "Invalid URL." + Error.NO_PUBLIC_KEY -> "Couldn't find server public key." +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt new file mode 100644 index 0000000000..262c3d2a7b --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessageV2.kt @@ -0,0 +1,69 @@ +package org.session.libsession.messaging.open_groups + +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsignal.service.internal.push.PushTransportDetails +import org.session.libsignal.service.internal.push.SignalServiceProtos +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Base64.decode +import org.session.libsignal.utilities.logging.Log +import org.whispersystems.curve25519.Curve25519 + +data class OpenGroupMessageV2( + val serverID: Long? = null, + val sender: String?, + val sentTimestamp: Long, + // The serialized protobuf in base64 encoding + val base64EncodedData: String, + // When sending a message, the sender signs the serialized protobuf with their private key so that + // a receiving user can verify that the message wasn't tampered with. + val base64EncodedSignature: String? = null +) { + + companion object { + private val curve = Curve25519.getInstance(Curve25519.BEST) + + fun fromJSON(json: Map): OpenGroupMessageV2? { + val base64EncodedData = json["data"] as? String ?: return null + val sentTimestamp = json["timestamp"] as? Long ?: return null + val serverID = json["server_id"] as? Int + val sender = json["public_key"] as? String + val base64EncodedSignature = json["signature"] as? String + return OpenGroupMessageV2(serverID = serverID?.toLong(), + sender = sender, + sentTimestamp = sentTimestamp, + base64EncodedData = base64EncodedData, + base64EncodedSignature = base64EncodedSignature + ) + } + + } + + fun sign(): OpenGroupMessageV2? { + if (base64EncodedData.isEmpty()) return null + val (publicKey, privateKey) = MessagingModuleConfiguration.shared.storage.getUserKeyPair() ?: return null + + if (sender != publicKey) return null // only sign our own messages? + + val signature = try { + curve.calculateSignature(privateKey, decode(base64EncodedData)) + } catch (e: Exception) { + Log.e("Loki", "Couldn't sign OpenGroupV2Message", e) + return null + } + + return copy(base64EncodedSignature = Base64.encodeBytes(signature)) + } + + fun toJSON(): Map { + val jsonMap = mutableMapOf("data" to base64EncodedData, "timestamp" to sentTimestamp) + serverID?.let { jsonMap["server_id"] = serverID } + sender?.let { jsonMap["public_key"] = sender } + base64EncodedSignature?.let { jsonMap["signature"] = base64EncodedSignature } + return jsonMap + } + + fun toProto(): SignalServiceProtos.Content = decode(base64EncodedData).let(PushTransportDetails::getStrippedPaddingMessageBody).let { bytes -> + SignalServiceProtos.Content.parseFrom(bytes) + } + +} \ 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 new file mode 100644 index 0000000000..2f9fd01394 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt @@ -0,0 +1,49 @@ +package org.session.libsession.messaging.open_groups + +import org.session.libsignal.utilities.JsonUtil +import java.util.* + +data class OpenGroupV2( + val server: String, + val room: String, + val id: String, + val name: String, + val publicKey: String +) { + + constructor(server: String, room: String, name: String, publicKey: String) : this( + server = server, + room = room, + id = "$server.$room", + name = name, + publicKey = publicKey, + ) + + companion object { + + fun fromJson(jsonAsString: String): OpenGroupV2? { + return try { + val json = JsonUtil.fromJson(jsonAsString) + if (!json.has("room")) return null + + val room = json.get("room").asText().toLowerCase(Locale.getDefault()) + val server = json.get("server").asText().toLowerCase(Locale.getDefault()) + val displayName = json.get("displayName").asText() + val publicKey = json.get("publicKey").asText() + + OpenGroupV2(server, room, displayName, publicKey) + } catch (e: Exception) { + null + } + } + + } + + fun toJson(): Map = mapOf( + "room" to room, + "server" to server, + "displayName" to name, + "publicKey" to publicKey, + ) + +} \ No newline at end of file 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 b09a016722..d6c2d82c76 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 @@ -7,7 +7,6 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.utilities.GroupUtil import org.session.libsignal.service.internal.push.PushTransportDetails import org.session.libsignal.service.internal.push.SignalServiceProtos -import org.session.libsignal.utilities.logging.Log object MessageReceiver { @@ -37,6 +36,7 @@ object MessageReceiver { is UnknownEnvelopeType -> false is InvalidSignature -> false is NoData -> false + is NoThread -> false is SenderBlocked -> false is SelfSend -> false else -> true @@ -122,7 +122,6 @@ object MessageReceiver { message.recipient = userPublicKey message.sentTimestamp = envelope.timestamp message.receivedTimestamp = if (envelope.hasServerTimestamp()) envelope.serverTimestamp else System.currentTimeMillis() - Log.d("Loki", "time: ${envelope.timestamp}, sent: ${envelope.serverTimestamp}") message.groupPublicKey = groupPublicKey message.openGroupServerMessageID = openGroupServerID // Validate 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 a09ff9d9ce..84afeff63b 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 @@ -12,14 +12,15 @@ import org.session.libsession.messaging.messages.control.ClosedGroupControlMessa import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.visible.* -import org.session.libsession.messaging.open_groups.OpenGroupAPI -import org.session.libsession.messaging.open_groups.OpenGroupMessage +import org.session.libsession.messaging.open_groups.* import org.session.libsession.messaging.threads.Address +import org.session.libsession.messaging.threads.recipients.Recipient import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.RawResponsePromise import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeModule import org.session.libsession.snode.SnodeMessage +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.service.internal.push.PushTransportDetails import org.session.libsignal.service.internal.push.SignalServiceProtos @@ -62,7 +63,7 @@ object MessageSender { // Convenience fun send(message: Message, destination: Destination): Promise { - if (destination is Destination.OpenGroup) { + if (destination is Destination.OpenGroup || destination is Destination.OpenGroupV2) { return sendToOpenGroupDestination(destination, message) } return sendToSnodeDestination(destination, message) @@ -90,7 +91,8 @@ object MessageSender { when (destination) { is Destination.Contact -> message.recipient = destination.publicKey is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey - is Destination.OpenGroup -> throw Error.PreconditionFailure("Destination should not be open groups!") + is Destination.OpenGroup, + is Destination.OpenGroupV2 -> throw Error.PreconditionFailure("Destination should not be open groups!") } // Validate the message if (!message.isValid()) { throw Error.InvalidMessage } @@ -109,9 +111,9 @@ object MessageSender { if (message is VisibleMessage) { val displayName = storage.getUserDisplayName()!! val profileKey = storage.getUserProfileKey() - val profilePrictureUrl = storage.getUserProfilePictureURL() - if (profileKey != null && profilePrictureUrl != null) { - message.profile = Profile(displayName, profileKey, profilePrictureUrl) + val profilePictureUrl = storage.getUserProfilePictureURL() + if (profileKey != null && profilePictureUrl != null) { + message.profile = Profile(displayName, profileKey, profilePictureUrl) } else { message.profile = Profile(displayName) } @@ -128,7 +130,8 @@ object MessageSender { val encryptionKeyPair = MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!! ciphertext = MessageSenderEncryption.encryptWithSessionProtocol(plaintext, encryptionKeyPair.hexEncodedPublicKey) } - is Destination.OpenGroup -> throw Error.PreconditionFailure("Destination should not be open groups!") + is Destination.OpenGroup, + is Destination.OpenGroupV2 -> throw Error.PreconditionFailure("Destination should not be open groups!") } // Wrap the result val kind: SignalServiceProtos.Envelope.Type @@ -142,7 +145,8 @@ object MessageSender { kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE senderPublicKey = destination.groupPublicKey } - is Destination.OpenGroup -> throw Error.PreconditionFailure("Destination should not be open groups!") + is Destination.OpenGroup, + is Destination.OpenGroupV2 -> throw Error.PreconditionFailure("Destination should not be open groups!") } val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) // Send the result @@ -150,6 +154,7 @@ object MessageSender { SnodeModule.shared.broadcaster.broadcast("calculatingPoW", message.sentTimestamp!!) } val base64EncodedData = Base64.encodeBytes(wrappedMessage) + // Send the result val snodeMessage = SnodeMessage(message.recipient!!, base64EncodedData, message.ttl, message.sentTimestamp!!) if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { SnodeModule.shared.broadcaster.broadcast("sendingMessage", message.sentTimestamp!!) @@ -204,32 +209,71 @@ object MessageSender { deferred.reject(error) } try { - val server: String - val channel: Long when (destination) { is Destination.Contact -> throw Error.PreconditionFailure("Destination should not be contacts!") is Destination.ClosedGroup -> throw Error.PreconditionFailure("Destination should not be closed groups!") is Destination.OpenGroup -> { message.recipient = "${destination.server}.${destination.channel}" - server = destination.server - channel = destination.channel + val server = destination.server + val channel = destination.channel + + // Validate the message + if (message !is VisibleMessage || !message.isValid()) { + throw Error.InvalidMessage + } + + // Convert the message to an open group message + val openGroupMessage = OpenGroupMessage.from(message, server) ?: run { + throw Error.InvalidMessage + } + // Send the result + OpenGroupAPI.sendMessage(openGroupMessage, channel, server).success { + message.openGroupServerMessageID = it.serverID + handleSuccessfulMessageSend(message, destination) + deferred.resolve(Unit) + }.fail { + handleFailure(it) + } + } + is Destination.OpenGroupV2 -> { + message.recipient = "${destination.server}.${destination.room}" + val server = destination.server + val room = destination.room + + // Attach the user's profile if needed + if (message is VisibleMessage) { + val displayName = storage.getUserDisplayName()!! + val profileKey = storage.getUserProfileKey() + val profilePictureUrl = storage.getUserProfilePictureURL() + if (profileKey != null && profilePictureUrl != null) { + message.profile = Profile(displayName, profileKey, profilePictureUrl) + } else { + message.profile = Profile(displayName) + } + } + + // Validate the message + if (message !is VisibleMessage || !message.isValid()) { + throw Error.InvalidMessage + } + + val proto = message.toProto()!! + + val openGroupMessage = OpenGroupMessageV2( + sender = message.sender, + sentTimestamp = message.sentTimestamp!!, + base64EncodedData = Base64.encodeBytes(proto.toByteArray()), + ) + + OpenGroupAPIV2.send(openGroupMessage,room,server).success { + message.openGroupServerMessageID = it.serverID + handleSuccessfulMessageSend(message, destination) + deferred.resolve(Unit) + }.fail { + handleFailure(it) + } + } - } - // Validate the message - if (message !is VisibleMessage || !message.isValid()) { - throw Error.InvalidMessage - } - // Convert the message to an open group message - val openGroupMessage = OpenGroupMessage.from(message, server) ?: kotlin.run { - throw Error.InvalidMessage - } - // Send the result - OpenGroupAPI.sendMessage(openGroupMessage, channel, server).success { - message.openGroupServerMessageID = it.serverID - handleSuccessfulMessageSend(message, destination) - deferred.resolve(Unit) - }.fail { - handleFailure(it) } } catch (exception: Exception) { handleFailure(exception) @@ -245,8 +289,12 @@ object MessageSender { // Ignore future self-sends storage.addReceivedMessageTimestamp(message.sentTimestamp!!) // Track the open group server message ID - if (message.openGroupServerMessageID != null) { - storage.setOpenGroupServerMessageID(messageId, message.openGroupServerMessageID!!) + if (message.openGroupServerMessageID != null && destination is Destination.OpenGroupV2) { + val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.room}".toByteArray()) + val threadID = storage.getThreadIdFor(Address.fromSerialized(encoded)) + if (threadID != null && threadID >= 0) { + storage.setOpenGroupServerMessageID(messageId, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage()) + } } // Mark the message as sent storage.markAsSent(message.sentTimestamp!!, message.sender?:userPublicKey) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 34da1396b7..49ead00424 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -125,8 +125,10 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closeGroup.publicKey, closeGroup.name, closeGroup.encryptionKeyPair!!, closeGroup.members, closeGroup.admins, message.sentTimestamp!!) } val allOpenGroups = storage.getAllOpenGroups().map { it.value.server } + val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.server } for (openGroup in message.openGroups) { if (allOpenGroups.contains(openGroup)) continue + // TODO: add in v2 storage.addOpenGroup(openGroup, 1) } if (message.displayName.isNotEmpty()) { @@ -149,16 +151,27 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context val userPublicKey = storage.getUserPublicKey() + + // Get or create thread + val threadID = storage.getOrCreateThreadIdFor(message.syncTarget + ?: message.sender!!, message.groupPublicKey, openGroupID) + + val openGroup = threadID.let { + storage.getOpenGroup(it.toString()) + } + // Update profile if needed val newProfile = message.profile - if (newProfile != null && openGroupID.isNullOrEmpty() && userPublicKey != message.sender) { + + if (newProfile != null && userPublicKey != message.sender && openGroup == null) { val profileManager = SSKEnvironment.shared.profileManager val recipient = Recipient.from(context, Address.fromSerialized(message.sender!!), false) val displayName = newProfile.displayName!! if (displayName.isNotEmpty()) { profileManager.setDisplayName(context, recipient, displayName) } - if (newProfile.profileKey?.isNotEmpty() == true && !MessageDigest.isEqual(recipient.profileKey, newProfile.profileKey)) { + if (newProfile.profileKey?.isNotEmpty() == true + && (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfile.profileKey))) { profileManager.setProfileKey(context, recipient, newProfile.profileKey!!) profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) val newUrl = newProfile.profilePictureURL @@ -170,9 +183,6 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS } } } - // Get or create thread - val threadID = storage.getOrCreateThreadIdFor(message.syncTarget - ?: message.sender!!, message.groupPublicKey, openGroupID) // Parse quote if needed var quoteModel: QuoteModel? = null if (message.quote != null && proto.dataMessage.hasQuote()) { @@ -213,7 +223,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS // Parse stickers if needed // Persist the message message.threadID = threadID - val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments) ?: throw MessageReceiver.Error.NoThread + val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments) ?: throw MessageReceiver.Error.DuplicateMessage // Parse & persist attachments // Start attachment downloads if needed storage.getAttachmentsForMessage(messageID).forEach { attachment -> @@ -222,6 +232,10 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS JobQueue.shared.add(downloadJob) } } + val openGroupServerID = message.openGroupServerMessageID + if (openGroupServerID != null) { + storage.setOpenGroupServerMessageID(messageID, openGroupServerID, threadID, !(message.isMediaMessage() || attachments.isNotEmpty())) + } // Cancel any typing indicators if needed cancelTypingIndicatorsIfNeeded(message.sender!!) //Notify the user if needed diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPoller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPoller.kt index 2e884c79f0..b9093e94cd 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPoller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPoller.kt @@ -69,9 +69,6 @@ class ClosedGroupPoller { // ignore inactive group's messages return@successBackground } - if (messages.isNotEmpty()) { - Log.d("Loki", "Received ${messages.count()} new message(s) in closed group with public key: $publicKey.") - } messages.forEach { envelope -> val job = MessageReceiveJob(envelope.toByteArray(), false) JobQueue.shared.add(job) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index acf7e23d4d..309bfa20bb 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -9,6 +9,8 @@ import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupAPI import org.session.libsession.messaging.open_groups.OpenGroupMessage +import org.session.libsession.messaging.threads.Address +import org.session.libsession.utilities.GroupUtil import org.session.libsignal.service.internal.push.SignalServiceProtos.* import org.session.libsignal.utilities.logging.Log import org.session.libsignal.utilities.successBackground @@ -72,7 +74,6 @@ class OpenGroupPoller(private val openGroup: OpenGroup, private val executorServ // Kovenant propagates a context to chained promises, so OpenGroupAPI.sharedContext should be used for all of the below OpenGroupAPI.getMessages(openGroup.channel, openGroup.server).successBackground { messages -> // Process messages in the background - Log.d("Loki", "received ${messages.size} messages") messages.forEach { message -> try { val senderPublicKey = message.senderPublicKey @@ -211,10 +212,13 @@ class OpenGroupPoller(private val openGroup: OpenGroup, private val executorServ } private fun pollForDeletedMessages() { + val messagingModule = MessagingModuleConfiguration.shared + val address = GroupUtil.getEncodedOpenGroupID(openGroup.id.toByteArray()) + val threadId = messagingModule.storage.getThreadIdFor(Address.fromSerialized(address)) ?: return OpenGroupAPI.getDeletedMessageServerIDs(openGroup.channel, openGroup.server).success { deletedMessageServerIDs -> - val deletedMessageIDs = deletedMessageServerIDs.mapNotNull { MessagingModuleConfiguration.shared.messageDataProvider.getMessageID(it) } - deletedMessageIDs.forEach { - MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(it) + val deletedMessageIDs = deletedMessageServerIDs.mapNotNull { messagingModule.messageDataProvider.getMessageID(it, threadId) } + deletedMessageIDs.forEach { (messageId, isSms) -> + MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(messageId, isSms) } }.fail { Log.d("Loki", "Failed to get deleted messages for group chat with ID: ${openGroup.channel} on server: ${openGroup.server}.") diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupV2Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupV2Poller.kt new file mode 100644 index 0000000000..311e213592 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupV2Poller.kt @@ -0,0 +1,130 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import nl.komponents.kovenant.Promise +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageReceiveJob +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.open_groups.OpenGroupMessageV2 +import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.threads.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.service.internal.push.SignalServiceProtos +import org.session.libsignal.utilities.logging.Log +import org.session.libsignal.utilities.successBackground +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class OpenGroupV2Poller(private val openGroups: List, private val executorService: ScheduledExecutorService? = null) { + + private var hasStarted = false + @Volatile private var isPollOngoing = false + var isCaughtUp = false + + private val cancellableFutures = mutableListOf>() + + // use this as a receive time-based window to calculate re-poll interval + private val receivedQueue = ArrayDeque(50) + + private fun calculatePollInterval(): Long { + // sample last default poll time * 2 + while (receivedQueue.size > 50) { + receivedQueue.removeLast() + } + val sampleWindow = System.currentTimeMillis() - pollForNewMessagesInterval * 2 + val numberInSample = receivedQueue.toList().filter { it > sampleWindow }.size.coerceAtLeast(1) + return ((2 + (50 / numberInSample / 20)*5) * 1000).toLong() + } + + // region Settings + companion object { + private val pollForNewMessagesInterval: Long = 10 * 1000 + } + // endregion + + // region Lifecycle + fun startIfNeeded() { + if (hasStarted || executorService == null) return + cancellableFutures += executorService.schedule(::compactPoll, 0, TimeUnit.MILLISECONDS) + hasStarted = true + } + + fun stop() { + cancellableFutures.forEach { future -> + future.cancel(false) + } + cancellableFutures.clear() + hasStarted = false + } + // endregion + + // region Polling + + private fun compactPoll(): Promise { + return compactPoll(false) + } + + fun compactPoll(isBackgroundPoll: Boolean): Promise { + if (isPollOngoing || !hasStarted) return Promise.of(Unit) + isPollOngoing = true + val server = openGroups.first().server // assume all the same server + val rooms = openGroups.map { it.room } + return OpenGroupAPIV2.getCompactPoll(rooms = rooms, server).successBackground { results -> + results.forEach { (room, results) -> + val serverRoomId = "$server.$room" + handleDeletedMessages(serverRoomId,results.deletions) + handleNewMessages(serverRoomId, results.messages.sortedBy { it.serverID }, isBackgroundPoll) + } + }.always { + isPollOngoing = false + if (!isBackgroundPoll) { + val delay = calculatePollInterval() + executorService?.schedule(this@OpenGroupV2Poller::compactPoll, delay, TimeUnit.MILLISECONDS) + } + } + } + + private fun handleNewMessages(serverRoomId: String, newMessages: List, isBackgroundPoll: Boolean) { + if (!hasStarted) return + newMessages.forEach { message -> + try { + val senderPublicKey = message.sender!! + // Main message + // Envelope + 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 + val envelope = builder.build() + val job = MessageReceiveJob(envelope.toByteArray(), isBackgroundPoll, message.serverID, serverRoomId) + Log.d("Loki", "Scheduling Job $job") + if (isBackgroundPoll) { + job.executeAsync() + // The promise is just used to keep track of when we're done + } else { + JobQueue.shared.add(job) + } + receivedQueue.addFirst(message.sentTimestamp) + } catch (e: Exception) { + Log.e("Loki", "Exception parsing message", e) + } + } + } + + private fun handleDeletedMessages(serverRoomId: String, deletedMessageServerIDs: List) { + val messagingModule = MessagingModuleConfiguration.shared + val address = GroupUtil.getEncodedOpenGroupID(serverRoomId.toByteArray()) + val threadId = messagingModule.storage.getThreadIdFor(Address.fromSerialized(address)) ?: return + + val deletedMessageIDs = deletedMessageServerIDs.mapNotNull { serverId -> + messagingModule.messageDataProvider.getMessageID(serverId, threadId) + } + deletedMessageIDs.forEach { (messageId, isSms) -> + MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(messageId, isSms) + } + } + // endregion +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt index 1fac9ca922..e93ed936e4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt @@ -10,7 +10,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.messaging.file_server.FileServerAPI -import org.session.libsignal.utilities.logging.Log import org.session.libsignal.utilities.DiffieHellman import org.session.libsignal.service.api.crypto.ProfileCipherOutputStream import org.session.libsignal.service.api.messages.SignalServiceAttachment @@ -27,7 +26,7 @@ import org.session.libsignal.service.loki.HTTP import org.session.libsignal.service.loki.utilities.* import org.session.libsignal.utilities.* import org.session.libsignal.utilities.Base64 - +import org.session.libsignal.utilities.logging.Log import java.io.File import java.io.FileOutputStream import java.io.OutputStream @@ -269,13 +268,13 @@ open class DotNetAPI { return upload(server, request) { json -> // Retrying is handled by AttachmentUploadJob val data = json["data"] as? Map<*, *> if (data == null) { - Log.d("Loki", "Couldn't parse attachment from: $json.") + Log.e("Loki", "Couldn't parse attachment from: $json.") throw Error.ParsingFailed } val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong() val url = data["url"] as? String if (id == null || url == null || url.isEmpty()) { - Log.d("Loki", "Couldn't parse upload from: $json.") + Log.e("Loki", "Couldn't parse upload from: $json.") throw Error.ParsingFailed } UploadResult(id, url, file.transmittedDigest) 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 ba103ee42c..086b911933 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -14,7 +14,6 @@ import org.session.libsignal.utilities.* import org.session.libsignal.service.loki.Snode import org.session.libsignal.service.loki.* import org.session.libsession.utilities.AESGCM.EncryptionResult -import org.session.libsignal.utilities.ThreadUtils import org.session.libsession.utilities.getBodyForOnionRequest import org.session.libsession.utilities.getHeadersForOnionRequest import org.session.libsignal.service.loki.Broadcaster @@ -82,7 +81,7 @@ object OnionRequestAPI { internal sealed class Destination { class Snode(val snode: org.session.libsignal.service.loki.Snode) : Destination() - class Server(val host: String, val target: String, val x25519PublicKey: String) : Destination() + class Server(val host: String, val target: String, val x25519PublicKey: String, val scheme: String, val port: Int) : Destination() } // region Private API @@ -330,7 +329,7 @@ object OnionRequestAPI { val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey) try { @Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) - val statusCode = json["status"] as Int + val statusCode = json["status_code"] as? Int ?: json["status"] as Int if (statusCode == 406) { @Suppress("NAME_SHADOWING") val body = mapOf( "result" to "Your clock is out of sync with the service node network." ) val exception = HTTPRequestFailedAtDestinationException(statusCode, body) @@ -454,7 +453,7 @@ object OnionRequestAPI { val urlAsString = url.toString() val host = url.host() val endpoint = when { - server.count() < urlAsString.count() -> urlAsString.substringAfter("$server/") + server.count() < urlAsString.count() -> urlAsString.substringAfter(server).removePrefix("/") else -> "" } val body = request.getBodyForOnionRequest() ?: "null" @@ -464,7 +463,8 @@ object OnionRequestAPI { "method" to request.method(), "headers" to headers ) - val destination = Destination.Server(host, target, x25519PublicKey) + url.isHttps + val destination = Destination.Server(host, target, x25519PublicKey, url.scheme(), url.port()) return sendOnionRequest(destination, payload, isJSONRequired).recover { exception -> Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.") throw exception diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt index 77de1c783c..c462e8f94f 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt @@ -70,7 +70,13 @@ object OnionRequestEncryption { payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key ) } is OnionRequestAPI.Destination.Server -> { - payload = mutableMapOf( "host" to rhs.host, "target" to rhs.target, "method" to "POST" ) + payload = mutableMapOf( + "host" to rhs.host, + "target" to rhs.target, + "method" to "POST", + "protocol" to rhs.scheme, + "port" to rhs.port + ) } } payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() 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 b4209f6ff5..e7bd5ce9e9 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -41,7 +41,7 @@ object SnodeAPI { if (useTestnet) { setOf( "http://public.loki.foundation:38157" ) } else { - setOf( "https://storage.seed1.loki.network:$seedNodePort", "https://storage.seed3.loki.network:$seedNodePort", "https://public.loki.foundation:$seedNodePort" ) + setOf( "https://storage.seed1.loki.network:$seedNodePort ", "https://storage.seed3.loki.network:$seedNodePort ", "https://public.loki.foundation:$seedNodePort" ) } } private val snodeFailureThreshold = 4 diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt index 086644b456..aa7fd4f6bb 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt @@ -16,10 +16,10 @@ data class SnodeMessage( internal fun toJSON(): Map { return mapOf( "pubKey" to if (SnodeAPI.useTestnet) recipient.removing05PrefixIfNeeded() else recipient, - "data" to data, - "ttl" to ttl.toString(), - "timestamp" to timestamp.toString(), - "nonce" to "" + "data" to data, + "ttl" to ttl.toString(), + "timestamp" to timestamp.toString(), + "nonce" to "" ) } } diff --git a/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt b/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt index fcbbf548d8..f5a170d8bd 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt @@ -1,14 +1,16 @@ package org.session.libsession.utilities -import org.whispersystems.curve25519.Curve25519 +import androidx.annotation.WorkerThread import org.session.libsignal.libsignal.util.ByteUtil import org.session.libsignal.service.internal.util.Util import org.session.libsignal.utilities.Hex +import org.whispersystems.curve25519.Curve25519 import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec +@WorkerThread internal object AESGCM { internal data class EncryptionResult( @@ -31,6 +33,16 @@ internal object AESGCM { return cipher.doFinal(ciphertext) } + /** + * Sync. Don't call from the main thread. + */ + internal fun generateSymmetricKey(x25519PublicKey: ByteArray, x25519PrivateKey: ByteArray): ByteArray { + val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(x25519PublicKey, x25519PrivateKey) + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256")) + return mac.doFinal(ephemeralSharedSecret) + } + /** * Sync. Don't call from the main thread. */ @@ -47,10 +59,7 @@ internal object AESGCM { internal fun encrypt(plaintext: ByteArray, hexEncodedX25519PublicKey: String): EncryptionResult { val x25519PublicKey = Hex.fromStringCondensed(hexEncodedX25519PublicKey) val ephemeralKeyPair = Curve25519.getInstance(Curve25519.BEST).generateKeyPair() - val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(x25519PublicKey, ephemeralKeyPair.privateKey) - val mac = Mac.getInstance("HmacSHA256") - mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256")) - val symmetricKey = mac.doFinal(ephemeralSharedSecret) + val symmetricKey = generateSymmetricKey(x25519PublicKey, ephemeralKeyPair.privateKey) val ciphertext = encrypt(plaintext, symmetricKey) return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.publicKey) } diff --git a/libsession/src/main/java/org/session/libsession/utilities/mentions/Mention.kt b/libsession/src/main/java/org/session/libsession/utilities/mentions/Mention.kt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libsignal/src/main/java/org/session/libsignal/service/loki/LokiAPIDatabaseProtocol.kt b/libsignal/src/main/java/org/session/libsignal/service/loki/LokiAPIDatabaseProtocol.kt index a771b38ef9..2b4fc4f32a 100644 --- a/libsignal/src/main/java/org/session/libsignal/service/loki/LokiAPIDatabaseProtocol.kt +++ b/libsignal/src/main/java/org/session/libsignal/service/loki/LokiAPIDatabaseProtocol.kt @@ -24,6 +24,11 @@ interface LokiAPIDatabaseProtocol { fun getLastDeletionServerID(group: Long, server: String): Long? fun setLastDeletionServerID(group: Long, server: String, newValue: Long) fun setUserCount(group: Long, server: String, newValue: Int) + fun getLastMessageServerID(room: String, server: String): Long? + fun setLastMessageServerID(room: String, server: String, newValue: Long) + fun getLastDeletionServerID(room: String, server: String): Long? + fun setLastDeletionServerID(room: String, server: String, newValue: Long) + fun setUserCount(room: String, server: String, newValue: Int) fun getSessionRequestSentTimestamp(publicKey: String): Long? fun setSessionRequestSentTimestamp(publicKey: String, newValue: Long) fun getSessionRequestProcessedTimestamp(publicKey: String): Long? diff --git a/libsignal/src/main/java/org/session/libsignal/service/loki/LokiMessageDatabaseProtocol.kt b/libsignal/src/main/java/org/session/libsignal/service/loki/LokiMessageDatabaseProtocol.kt index 694d989af7..3f4c21e341 100644 --- a/libsignal/src/main/java/org/session/libsignal/service/loki/LokiMessageDatabaseProtocol.kt +++ b/libsignal/src/main/java/org/session/libsignal/service/loki/LokiMessageDatabaseProtocol.kt @@ -3,5 +3,5 @@ package org.session.libsignal.service.loki interface LokiMessageDatabaseProtocol { fun getQuoteServerID(quoteID: Long, quoteePublicKey: String): Long? - fun setServerID(messageID: Long, serverID: Long) + fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java b/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java index f5767a8da2..046afeb34f 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java +++ b/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java @@ -3,8 +3,10 @@ package org.session.libsignal.utilities; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.ResolvedType; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonSerializer; @@ -42,6 +44,10 @@ public class JsonUtil { return objectMapper.readValue(serialized, clazz); } + public static T fromJson(String serialized, JavaType clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + public static T fromJson(InputStream serialized, Class clazz) throws IOException { return objectMapper.readValue(serialized, clazz); }