diff --git a/BUILDING.md b/BUILDING.md index 142069ff01..5fa550b3f9 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -21,15 +21,6 @@ Ensure that the following packages are installed from the Android SDK manager: In Android studio, this can be done from the Quickstart panel, choose "Configure" then "SDK Manager". In the SDK Tools tab of the SDK Manager, make sure that the "Android Support Repository" is installed, and that the latest "Android SDK build-tools" are installed. Click "OK" to return to the Quickstart panel. You may also need to install API version 28 in the SDK platforms tab. -You will then need to clone and run `./gradlew install` on each of the following repositories IN ORDER: - -* https://github.com/loki-project/loki-messenger-android-curve-25519 -* https://github.com/loki-project/loki-messenger-android-protocol -* https://github.com/loki-project/loki-messenger-android-meta -* https://github.com/loki-project/session-android-service - -This installs these dependencies into a local Maven repository which the main Session Android repository will then draw from. - Setting up a development environment and building from Android Studio ------------------------------------ @@ -37,7 +28,7 @@ Setting up a development environment and building from Android Studio 1. Open Android Studio. On a new installation, the Quickstart panel will appear. If you have open projects, close them using "File > Close Project" to see the Quickstart panel. 2. From the Quickstart panel, choose "Checkout from Version Control" then "git". -3. Paste the URL for the session-android project when prompted (https://github.com/loki-project/session-android.git). +3. Paste the URL for the session-android project when prompted (https://github.com/oxen-io/session-android.git). 4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes". 5. Default config options should be good enough. 6. Project initialization and building should proceed. @@ -49,7 +40,7 @@ The following steps should help you (re)build Session from the command line once 1. Checkout the session-android project source with the command: - git clone https://github.com/loki-project/session-android.git + git clone https://github.com/oxen-io/session-android.git 2. Make sure you have the [Android SDK](https://developer.android.com/sdk/index.html) installed. 3. Create a local.properties file at the root of your source checkout and add an sdk.dir entry to it. For example: @@ -58,7 +49,7 @@ The following steps should help you (re)build Session from the command line once 4. Execute Gradle: - ./gradlew build + ./gradlew :app:build Contributing code ----------------- diff --git a/README.md b/README.md index e7a7279ea6..5e41159c45 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,19 @@ [Download on the Google Play Store](https://getsession.org/android) +Add the [F-Droid repo](https://fdroid.getsession.org/) + [Grab the APK here](https://github.com/loki-project/session-android/releases/latest) ## Summary -Session integrates directly with [Loki Service Nodes](https://lokidocs.com/ServiceNodes/SNOverview/), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper). +Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper). ![AndroidSession](https://i.imgur.com/0YC9TyI.png) ## Want to contribute? Found a bug or have a feature request? -Please search for any [existing issues](https://github.com/loki-project/session-android/issues) that describe your bugs in order to avoid duplicate submissions. Submissions can be made by making a pull request to our development branch. If you don't know where to start contributing, try reading the Github issues page for ideas. +Please search for any [existing issues](https://github.com/oxen-io/session-android/issues) that describe your bugs in order to avoid duplicate submissions. Submissions can be made by making a pull request to our `dev` branch. If you don't know where to start contributing, try reading the Github issues page for ideas. ## Build instructions diff --git a/app/build.gradle b/app/build.gradle index 575c1b3b39..0b6754d2f9 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" @@ -157,8 +158,8 @@ dependencies { testImplementation 'org.robolectric:shadows-multidex:4.2' } -def canonicalVersionCode = 151 -def canonicalVersionName = "1.9.1" +def canonicalVersionCode = 157 +def canonicalVersionName = "1.10.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java index b058c99c91..ad75d60330 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -84,6 +84,7 @@ public class PassphrasePromptActivity extends BaseActionBarActivity { } @Override public void onServiceDisconnected(ComponentName name) { + keyCachingService.setMasterSecret(new Object()); keyCachingService = null; } }, Context.BIND_AUTO_CREATE); @@ -133,7 +134,9 @@ public class PassphrasePromptActivity extends BaseActionBarActivity { private void handleAuthenticated() { authenticated = true; //TODO Replace with a proper call. - keyCachingService.setMasterSecret(new Object()); + if (keyCachingService != null) { + keyCachingService.setMasterSecret(new Object()); + } // Finish and proceed with the next intent. Intent nextIntent = getIntent().getParcelableExtra("next_intent"); @@ -188,6 +191,8 @@ public class PassphrasePromptActivity extends BaseActionBarActivity { if (!keyguardManager.isKeyguardSecure()) { Log.w(TAG ,"Keyguard not secure..."); + TextSecurePreferences.setScreenLockEnabled(getApplicationContext(), false); + TextSecurePreferences.setScreenLockTimeout(getApplicationContext(), 0); handleAuthenticated(); return; } 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..0f449c9f3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import okhttp3.HttpUrl import org.session.libsession.messaging.StorageProtocol import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.Job @@ -13,6 +14,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 +228,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 +252,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 +280,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 +319,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) } @@ -315,9 +379,9 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, SessionMetaProtocol.addTimestamp(timestamp) } -// override fun removeReceivedMessageTimestamps(timestamps: Set) { -// TODO("Not yet implemented") -// } + override fun removeReceivedMessageTimestamps(timestamps: Set) { + SessionMetaProtocol.removeTimestamps(timestamps) + } override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? { val database = DatabaseFactory.getMmsSmsDatabase(context) @@ -325,8 +389,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,8 +540,27 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } - override fun addOpenGroup(server: String, channel: Long) { - OpenGroupUtilities.addGroup(context, server, channel) + override fun getAllV2OpenGroups(): Map { + return DatabaseFactory.getLokiThreadDatabase(context).getAllV2OpenGroups() + } + + override fun addOpenGroup(serverUrl: String, channel: Long) { + val httpUrl = HttpUrl.parse(serverUrl) ?: return + if (httpUrl.queryParameterNames().contains("public_key")) { + // open group v2 + 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 room = httpUrl.pathSegments().firstOrNull() ?: return + val publicKey = httpUrl.queryParameter("public_key") ?: return + + OpenGroupUtilities.addGroup(context, server.toString().removeSuffix("/"), room, publicKey) + } else { + OpenGroupUtilities.addGroup(context, serverUrl, channel) + } } override fun getAllGroups(): List { @@ -513,6 +597,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/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 258070f4b2..1ebfa6146b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.loki.protocol.SessionMetaProtocol; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; @@ -410,6 +411,7 @@ public class ThreadDatabase extends Database { deleteThread(threadId); notifyConversationListeners(threadId); notifyConversationListListeners(); + SessionMetaProtocol.clearReceivedMessages(); } public boolean hasThread(long threadId) { 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..ef04bedbb7 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 @@ -55,9 +55,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV21 = 42; private static final int lokiV22 = 43; private static final int lokiV23 = 44; + private static final int lokiV24 = 45; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV23; + private static final int DATABASE_VERSION = lokiV24; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -125,6 +126,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 +278,17 @@ 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()); + } + + if (oldVersion < lokiV24) { + String swarmTable = LokiAPIDatabase.Companion.getSwarmTable(); + String snodePoolTable = LokiAPIDatabase.Companion.getSnodePoolTable(); + db.execSQL("DROP TABLE " + swarmTable); + db.execSQL("DROP TABLE " + snodePoolTable); + db.execSQL(LokiAPIDatabase.getCreateSnodePoolTableCommand()); + db.execSQL(LokiAPIDatabase.getCreateSwarmTableCommand()); } 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..bc53e1534d 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 @@ -27,7 +27,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( private val timestamp = "timestamp" private val snode = "snode" // Snode pool - private val snodePoolTable = "loki_snode_pool_cache" + public val snodePoolTable = "loki_snode_pool_cache" private val dummyKey = "dummy_key" private val snodePool = "snode_pool_key" @JvmStatic val createSnodePoolTableCommand = "CREATE TABLE $snodePoolTable ($dummyKey TEXT PRIMARY KEY, $snodePool TEXT);" @@ -36,7 +36,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( private val indexPath = "index_path" @JvmStatic val createOnionRequestPathTableCommand = "CREATE TABLE $onionRequestPathTable ($indexPath TEXT PRIMARY KEY, $snode TEXT);" // Swarms - private val swarmTable = "loki_api_swarm_cache" + public val swarmTable = "loki_api_swarm_cache" private val swarmPublicKey = "hex_encoded_public_key" private val swarm = "swarm" @JvmStatic val createSwarmTableCommand = "CREATE TABLE $swarmTable ($swarmPublicKey TEXT PRIMARY KEY, $swarm TEXT);" @@ -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/protocol/MultiDeviceProtocol.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/MultiDeviceProtocol.kt index f25833902e..c7ba42ecf5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/MultiDeviceProtocol.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/MultiDeviceProtocol.kt @@ -17,7 +17,7 @@ object MultiDeviceProtocol { val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) val now = System.currentTimeMillis() - if (now - lastSyncTime < 2 * 24 * 60 * 60 * 1000) return + if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> !recipient.isBlocked && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() }.map { recipient -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/SessionMetaProtocol.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/SessionMetaProtocol.kt index ded1b933c2..efc83d0812 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/SessionMetaProtocol.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/SessionMetaProtocol.kt @@ -24,6 +24,15 @@ object SessionMetaProtocol { timestamps.add(timestamp) } + @JvmStatic + fun clearReceivedMessages() { + timestamps.clear() + } + + fun removeTimestamps(timestamps: Set) { + this.timestamps.removeAll(timestamps) + } + @JvmStatic fun shouldIgnoreMessage(timestamp: Long): Boolean { val shouldIgnoreMessage = timestamps.contains(timestamp) 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/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java index db9484f9b0..e516916de4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java @@ -102,7 +102,8 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment if (duration == 0) { TextSecurePreferences.setScreenLockTimeout(getContext(), 0); } else { - long timeoutSeconds = Math.max(TimeUnit.MILLISECONDS.toSeconds(duration), 60); + long timeoutSeconds = TimeUnit.MILLISECONDS.toSeconds(duration); +// long timeoutSeconds = Math.max(TimeUnit.MILLISECONDS.toSeconds(duration), 60); TextSecurePreferences.setScreenLockTimeout(getContext(), timeoutSeconds); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java index e1c2350ef1..e204c3f503 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -119,7 +119,6 @@ public class KeyCachingService extends Service { KeyCachingService.masterSecret = masterSecret; foregroundService(); - startTimeoutIfAppropriate(this); new AsyncTask() { @Override @@ -210,7 +209,7 @@ public class KeyCachingService extends Service { boolean passLockActive = timeoutEnabled && !TextSecurePreferences.isPasswordDisabled(context); long screenTimeout = TextSecurePreferences.getScreenLockTimeout(context); - boolean screenLockActive = screenTimeout >= 60 && TextSecurePreferences.isScreenLockEnabled(context); + boolean screenLockActive = screenTimeout >= 0 && TextSecurePreferences.isScreenLockEnabled(context); if (!appVisible && secretSet && (passLockActive || screenLockActive)) { long passphraseTimeoutMinutes = TextSecurePreferences.getPassphraseTimeoutInterval(context); 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..f64aa1a032 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 addOpenGroup(serverUrl: String, channel: Long) + fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) fun getQuoteServerID(quoteID: Long, publicKey: String): Long? // Open Group Public Keys @@ -69,31 +72,30 @@ 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 fun getReceivedMessageTimestamps(): Set fun addReceivedMessageTimestamp(timestamp: Long) -// fun removeReceivedMessageTimestamps(timestamps: Set) + fun removeReceivedMessageTimestamps(timestamps: Set) // Returns the IDs of the saved attachments. fun persistAttachments(messageId: Long, attachments: List): List fun getAttachmentsForMessage(messageId: Long): List @@ -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/file_server/FileServerAPIV2.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt new file mode 100644 index 0000000000..9469514871 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerAPIV2.kt @@ -0,0 +1,106 @@ +package org.session.libsession.messaging.file_server + +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 +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsignal.service.loki.HTTP +import org.session.libsignal.service.loki.utilities.retryIfNeeded +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.logging.Log + +object FileServerAPIV2 { + + const val DEFAULT_SERVER = "http://88.99.175.227" + private const val DEFAULT_SERVER_PUBLIC_KEY = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69" + + sealed class Error : Exception() { + object PARSING_FAILED : Error() + object INVALID_URL : Error() + + fun errorDescription() = when (this) { + PARSING_FAILED -> "Invalid response." + INVALID_URL -> "Invalid URL." + } + + } + + data class Request( + val verb: HTTP.Verb, + val endpoint: String, + val queryParameters: Map = mapOf(), + val parameters: Any? = null, + val headers: Map = mapOf(), + // 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): Promise, Exception> { + val parsed = HttpUrl.parse(DEFAULT_SERVER) ?: return Promise.ofFail(OpenGroupAPIV2.Error.INVALID_URL) + val urlBuilder = HttpUrl.Builder() + .scheme(parsed.scheme()) + .host(parsed.host()) + .port(parsed.port()) + .addPathSegments(request.endpoint) + + if (request.verb == HTTP.Verb.GET) { + for ((key, value) in request.queryParameters) { + urlBuilder.addQueryParameter(key, value) + } + } + + val requestBuilder = okhttp3.Request.Builder() + .url(urlBuilder.build()) + .headers(Headers.of(request.headers)) + when (request.verb) { + HTTP.Verb.GET -> requestBuilder.get() + HTTP.Verb.PUT -> requestBuilder.put(createBody(request.parameters)!!) + HTTP.Verb.POST -> requestBuilder.post(createBody(request.parameters)!!) + HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.parameters)) + } + + if (request.useOnionRouting) { + return OnionRequestAPI.sendOnionRequest(requestBuilder.build(), DEFAULT_SERVER, DEFAULT_SERVER_PUBLIC_KEY) + .fail { e -> + Log.e("Loki", "FileServerV2 failed with error",e) + } + } else { + return Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) + } + + } + + // region Sending + fun upload(file: ByteArray): Promise { + val base64EncodedFile = Base64.encodeBytes(file) + val parameters = mapOf("file" to base64EncodedFile) + val request = Request(verb = HTTP.Verb.POST, endpoint = "files", parameters = parameters) + return send(request).map { json -> + json["result"] as? Long ?: throw OpenGroupAPIV2.Error.PARSING_FAILED + } + } + + fun download(file: Long): Promise { + val request = Request(verb = HTTP.Verb.GET, endpoint = "files/$file") + return send(request).map { json -> + val base64EncodedFile = json["result"] as? String ?: throw Error.PARSING_FAILED + Base64.decode(base64EncodedFile) ?: throw Error.PARSING_FAILED + } + } + +} \ No newline at end of file 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..6855b08b02 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,16 +1,20 @@ 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.file_server.FileServerAPIV2 +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.libsession.utilities.DownloadUtilities import org.session.libsignal.service.api.crypto.AttachmentCipherInputStream import org.session.libsignal.utilities.Base64 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 +26,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) // Settings override val maxFailureCount: Int = 20 + companion object { val KEY: String = "AttachmentDownloadJob" @@ -46,18 +51,28 @@ 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) - - // 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 threadId = MessagingModuleConfiguration.shared.storage.getThreadIdForMms(databaseMessageID) + val openGroupV2 = MessagingModuleConfiguration.shared.storage.getV2OpenGroup(threadId.toString()) + val stream = if (openGroupV2 == null) { + DownloadUtilities.downloadFile(tempFile, attachment.url, FileServerAPI.maxFileSize, 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 +109,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/messages/control/ConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt index 29aa13e586..310ec0c019 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt @@ -113,8 +113,11 @@ class ConfigurationMessage(var closedGroups: List, var openGroups: } if (groupRecord.isOpenGroup) { val threadID = storage.getThreadID(groupRecord.encodedId) ?: continue - val openGroup = storage.getOpenGroup(threadID) ?: continue - openGroups.add(openGroup.server) + val openGroup = storage.getOpenGroup(threadID) + val openGroupV2 = storage.getV2OpenGroup(threadID) + + val shareUrl = openGroup?.server ?: openGroupV2?.toJoinUrl() ?: continue + openGroups.add(shareUrl) } } 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..7714a66e5d --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupAPIV2.kt @@ -0,0 +1,506 @@ +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() + + fun 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." + } + + } + + 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 + +} \ 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..29965079cf --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupV2.kt @@ -0,0 +1,51 @@ +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 toJoinUrl(): String = "$server/$room?public_key=$publicKey" + + 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 5ddc1a7a62..7fada09958 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 @@ -1,6 +1,7 @@ package org.session.libsession.messaging.sending_receiving import android.text.TextUtils +import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue @@ -128,8 +129,9 @@ 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.toJoinUrl() } for (openGroup in message.openGroups) { - if (allOpenGroups.contains(openGroup)) continue + if (allOpenGroups.contains(openGroup) || allV2OpenGroups.contains(openGroup)) continue storage.addOpenGroup(openGroup, 1) } if (message.displayName.isNotEmpty()) { @@ -164,16 +166,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 @@ -185,9 +198,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()) { @@ -228,7 +238,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 -> @@ -237,6 +247,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..c3ad4c368b 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 @@ -179,80 +178,9 @@ open class DotNetAPI { return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters) } - // DOWNLOAD - - /** - * Blocks the calling thread. - */ - fun downloadFile(destination: File, url: String, listener: SignalServiceAttachment.ProgressListener?) { - val outputStream = FileOutputStream(destination) // Throws - var remainingAttempts = 4 - var exception: Exception? = null - while (remainingAttempts > 0) { - remainingAttempts -= 1 - try { - downloadFile(outputStream, url, listener) - exception = null - break - } catch (e: Exception) { - exception = e - } - } - if (exception != null) { throw exception } - } - - /** - * Blocks the calling thread. - */ - fun downloadFile(outputStream: OutputStream, url: String, listener: SignalServiceAttachment.ProgressListener?) { - // We need to throw a PushNetworkException or NonSuccessfulResponseCodeException - // because the underlying Signal logic requires these to work correctly - val oldPrefixedHost = "https://" + HttpUrl.get(url).host() - var newPrefixedHost = oldPrefixedHost - if (oldPrefixedHost.contains(FileServerAPI.fileStorageBucketURL)) { - newPrefixedHost = FileServerAPI.shared.server - } - // Edge case that needs to work: https://file-static.lokinet.org/i1pNmpInq3w9gF3TP8TFCa1rSo38J6UM - // → https://file.getsession.org/loki/v1/f/XLxogNXVEIWHk14NVCDeppzTujPHxu35 - val fileID = url.substringAfter(oldPrefixedHost).substringAfter("/f/") - val sanitizedURL = "$newPrefixedHost/loki/v1/f/$fileID" - val request = Request.Builder().url(sanitizedURL).get() - try { - val serverPublicKey = if (newPrefixedHost.contains(FileServerAPI.shared.server)) FileServerAPI.fileServerPublicKey - else FileServerAPI.shared.getPublicKeyForOpenGroupServer(newPrefixedHost).get() - val json = OnionRequestAPI.sendOnionRequest(request.build(), newPrefixedHost, serverPublicKey, isJSONRequired = false).get() - val result = json["result"] as? String - if (result == null) { - Log.d("Loki", "Couldn't parse attachment from: $json.") - throw PushNetworkException("Missing response body.") - } - val body = Base64.decode(result) - if (body.size > FileServerAPI.maxFileSize) { - Log.d("Loki", "Attachment size limit exceeded.") - throw PushNetworkException("Max response size exceeded.") - } - val input = body.inputStream() - val buffer = ByteArray(32768) - var count = 0 - var bytes = input.read(buffer) - while (bytes >= 0) { - outputStream.write(buffer, 0, bytes) - count += bytes - if (count > FileServerAPI.maxFileSize) { - Log.d("Loki", "Attachment size limit exceeded.") - throw PushNetworkException("Max response size exceeded.") - } - listener?.onAttachmentProgress(body.size.toLong(), count.toLong()) - bytes = input.read(buffer) - } - } catch (e: Exception) { - Log.d("Loki", "Couldn't download attachment due to error: $e.") - throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e) - } - } - // UPLOAD + // TODO: migrate to v2 file server @Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class) fun uploadAttachment(server: String, attachment: PushAttachmentData): UploadResult { // This function mimics what Signal does in PushServiceSocket @@ -269,13 +197,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..a5094a63ee 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -33,7 +33,7 @@ object SnodeAPI { // Settings private val maxRetryCount = 6 - private val minimumSnodePoolCount = 24 + private val minimumSnodePoolCount = 12 private val minimumSwarmSnodeCount = 2 // Use port 4433 if the API level can handle the network security configuration and enforce pinned certificates private val seedNodePort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4433 @@ -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 @@ -92,6 +92,7 @@ object SnodeAPI { "method" to "get_n_service_nodes", "params" to mapOf( "active_only" to true, + "limit" to 256, "fields" to mapOf( "public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true ) ) ) 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/DownloadUtilities.kt b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt index 1c0e29f045..9ea2d8e3a5 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt @@ -3,7 +3,9 @@ package org.session.libsession.utilities import okhttp3.HttpUrl import okhttp3.Request import org.session.libsession.messaging.file_server.FileServerAPI +import org.session.libsession.messaging.file_server.FileServerAPIV2 import org.session.libsession.snode.OnionRequestAPI +import org.session.libsignal.service.api.crypto.AttachmentCipherInputStream import org.session.libsignal.utilities.logging.Log import org.session.libsignal.service.api.messages.SignalServiceAttachment import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException @@ -39,50 +41,64 @@ object DownloadUtilities { */ @JvmStatic fun downloadFile(outputStream: OutputStream, url: String, maxSize: Int, listener: SignalServiceAttachment.ProgressListener?) { - // We need to throw a PushNetworkException or NonSuccessfulResponseCodeException - // because the underlying Signal logic requires these to work correctly - val oldPrefixedHost = "https://" + HttpUrl.get(url).host() - var newPrefixedHost = oldPrefixedHost - if (oldPrefixedHost.contains(FileServerAPI.fileStorageBucketURL)) { - newPrefixedHost = FileServerAPI.shared.server - } - // Edge case that needs to work: https://file-static.lokinet.org/i1pNmpInq3w9gF3TP8TFCa1rSo38J6UM - // → https://file.getsession.org/loki/v1/f/XLxogNXVEIWHk14NVCDeppzTujPHxu35 - val fileID = url.substringAfter(oldPrefixedHost).substringAfter("/f/") - val sanitizedURL = "$newPrefixedHost/loki/v1/f/$fileID" - val request = Request.Builder().url(sanitizedURL).get() - try { - val serverPublicKey = if (newPrefixedHost.contains(FileServerAPI.shared.server)) FileServerAPI.fileServerPublicKey - else FileServerAPI.shared.getPublicKeyForOpenGroupServer(newPrefixedHost).get() - val json = OnionRequestAPI.sendOnionRequest(request.build(), newPrefixedHost, serverPublicKey, isJSONRequired = false).get() - val result = json["result"] as? String - if (result == null) { - Log.d("Loki", "Couldn't parse attachment from: $json.") - throw PushNetworkException("Missing response body.") - } - val body = Base64.decode(result) - if (body.size > maxSize) { - Log.d("Loki", "Attachment size limit exceeded.") - throw PushNetworkException("Max response size exceeded.") - } - body.inputStream().use { input -> - val buffer = ByteArray(32768) - var count = 0 - var bytes = input.read(buffer) - while (bytes >= 0) { - outputStream.write(buffer, 0, bytes) - count += bytes - if (count > maxSize) { - Log.d("Loki", "Attachment size limit exceeded.") - throw PushNetworkException("Max response size exceeded.") - } - listener?.onAttachmentProgress(body.size.toLong(), count.toLong()) - bytes = input.read(buffer) + + if (url.contains(FileServerAPIV2.DEFAULT_SERVER)) { + val httpUrl = HttpUrl.parse(url)!! + val fileId = httpUrl.pathSegments().last() + try { + FileServerAPIV2.download(fileId.toLong()).get().let { + outputStream.write(it) } + } catch (e: Exception) { + Log.e("Loki", "Couln't download attachment due to error",e) + throw e + } + } else { + // We need to throw a PushNetworkException or NonSuccessfulResponseCodeException + // because the underlying Signal logic requires these to work correctly + val oldPrefixedHost = "https://" + HttpUrl.get(url).host() + var newPrefixedHost = oldPrefixedHost + if (oldPrefixedHost.contains(FileServerAPI.fileStorageBucketURL)) { + newPrefixedHost = FileServerAPI.shared.server + } + // Edge case that needs to work: https://file-static.lokinet.org/i1pNmpInq3w9gF3TP8TFCa1rSo38J6UM + // → https://file.getsession.org/loki/v1/f/XLxogNXVEIWHk14NVCDeppzTujPHxu35 + val fileID = url.substringAfter(oldPrefixedHost).substringAfter("/f/") + val sanitizedURL = "$newPrefixedHost/loki/v1/f/$fileID" + val request = Request.Builder().url(sanitizedURL).get() + try { + val serverPublicKey = if (newPrefixedHost.contains(FileServerAPI.shared.server)) FileServerAPI.fileServerPublicKey + else FileServerAPI.shared.getPublicKeyForOpenGroupServer(newPrefixedHost).get() + val json = OnionRequestAPI.sendOnionRequest(request.build(), newPrefixedHost, serverPublicKey, isJSONRequired = false).get() + val result = json["result"] as? String + if (result == null) { + Log.d("Loki", "Couldn't parse attachment from: $json.") + throw PushNetworkException("Missing response body.") + } + val body = Base64.decode(result) + if (body.size > maxSize) { + Log.d("Loki", "Attachment size limit exceeded.") + throw PushNetworkException("Max response size exceeded.") + } + body.inputStream().use { input -> + val buffer = ByteArray(32768) + var count = 0 + var bytes = input.read(buffer) + while (bytes >= 0) { + outputStream.write(buffer, 0, bytes) + count += bytes + if (count > maxSize) { + Log.d("Loki", "Attachment size limit exceeded.") + throw PushNetworkException("Max response size exceeded.") + } + listener?.onAttachmentProgress(body.size.toLong(), count.toLong()) + bytes = input.read(buffer) + } + } + } catch (e: Exception) { + Log.e("Loki", "Couldn't download attachment due to error", e) + throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e) } - } catch (e: Exception) { - Log.d("Loki", "Couldn't download attachment due to error: $e.") - throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e) } } } \ No newline at end of file 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); }