From ac18f1cbfe5df335234def8a8c35235513044089 Mon Sep 17 00:00:00 2001 From: 0x330a <92654767+0x330a@users.noreply.github.com> Date: Fri, 14 Jul 2023 18:27:13 +1000 Subject: [PATCH] Integrate shared libsession-util library (#1096) * feat: add some config db basics and DI for it, make the user profile optional, start looking at integrate building from initial dump * update: get latest util library submodule update * refactor: fix compile for refactored API * refactor: naming consistent with library * feat: add in config storage and injection to common places, managing lifecycle of native instances * refactor: config database changes, new protos, adding in support for config base namespace queries * refactor: config query and store use the same format as other platforms * feat: add batch snode calls and try to poll from all the config namespaces * fix: add optional namespace in signature and params * feat: add raw requests for modifying expiry and getting expiries * feat: add some base config migration logic, start implementing wrappers for conversation and expiry types * chore: update libsession base * feat: start integrating conversation wrapper functions * feat: add basic conversation info volatile types and implementations, start working on tests * feat: more common library wrapper implementation and test * fix: tests and compile issues * fix: fix tests, don't use iterables * feat: add all iterators and tests * feat: add in more config factory for volatile * feat: update request responses and their appropriate processing * feat: add storage with hashes and some basic profile update logic in config factory probably move that somewhere else * feat: adding config sync functionality, refactoring jobs to execute in suspend context to do some nice coroutine execution * refactor: moving some properties around so we have access in libsession * feat: expand on the config sync job, finish basic implementation to test against * feat: add forced config sync * feat: syncs the user profile stuff for now, and errors back to placeholder instead of unknown recipient * feat: add basic message read logic for synchronizing last reads, need to modify the query to use the last seen instead of the unread count in a subquery possibly for thread display record * feat: add broken unreads everywhere * fix: unreads work now for incoming messages, need to sync conv volatile properly still * feat: batching poll responses properly and handling groups properly * fix: replace the mark read receiver (from notifications) to use the new set last seen mark read logic * feat: update to the group list branch * fix: compile errors from updating library to use latest branch, now requires cmake 3.22.1 * fix: fix the contact tests * fix: getters weren't getters properly in the config factory, fixed new onboarding from configs * feat: add the last seen * feat: start adding user groups wrapper objects * refactor: add more else branches for unimplemented types * feat: buffer the last read when in conversation * feat: add basic contact logic for setting local contact state. Need to implement handling properly * refactor: trying to just include blocked status for now in updating contacts * fix: add some more contact syncing: nicknames, approved statuses, blocked statuses * feat: start implementing hashes in shared lib and refactoring * feat: start to implement group list info classes and wrappers and refactor to use library based hashes * feat: incorporate hashes from library, more wrapper for user groups and serialization from c++ * feat: adding more serialization changes for community base info and user groups LGC * feat: adding more serialize deserialize to legacy closed groups * feat: finish serial/deserial helper * feat: just implement deserialize community info * refactor: refactor tests and wrappers to use less pointers, finish implementing user groups API * feat: finish latest wrappers fix tests and continue building default generation functions. refactor defaults to be used if no stored data blob in DB * feat: more usergroup functionality, storage functionality for checking pinned status, adding pinned status for NTS/contacts, move community info parse full url to base community, add StorageProtocol logic for group info * feat: adding user groups to the list of user configs, refactorign some of the config factory to fetch the user configs easier. Add handling for polling user group namespace * feat: implement the default user config list * feat: add user group config handling * chore: extra missed existing group * refactor: use existing lookup for objects in wrappers so they don't overwrite missing values * feat: add contacts expiry serialization/deserialization, more LGC, timestamps to add closed group encryption info (for latest tracking) * refactor: change how expiration timer works for contacts, set the expiration timer for those conversations in handling contact configs * feat: add expiration updates via config for contacts as well * feat: add almost all group editing cases, need to hook into the thread deletion for groups in the user groups * feat: open group joining should work now * feat: add groups to configs for push * fix: handling user group updates bug fix for closed groups instead of all groups * fix: open group sync persistence * feat: add in activity finish if recipient no longer exists (deleted thread) from sync * feat: support avatar removal from shared library * feat: support thread deletion and refactoring a lot of getOrCreateThread references to go via storage or assume they are correctly set to hook into the contact and volatile creation during thread creation * fix: database update not deleting in certain circumstances, storage persisting and removing the volatile convo info for thread deletion / creation, NTS hidden getter values in shared library * refactor: make update listener visibility package * refactor: update kotlin * feat: update dependencies and support outdated config messages, refactor config factory to return null configs if new configs not supported * feat: update shared library to use priority only, fix compile errors, fix group member sync problem * fix: compile error * fix: profile avatar fixes for local user now that we aren't setting local user profile key * Revert "fix: profile avatar fixes for local user now that we aren't setting local user profile key" This reverts commit 3f569e34034713ee230581bc118e9127a8d0f434. * refactor: let the local number update recipient details in profile manager * fix: don't recreate thread after leaving * fix: fix up the duplicate thread creation in the message receive handler * fix: fix the placeholder rendering on new messages, add in extra context logging for adding contacts and preventing new thread creation on new messages of various types * feat: add test theme for xml layout previews * feat: add shortened hex for session IDs throughout, replace nullable getName with null in underlying contacts for individual contacts, build shared lib with release mode, remove todo, fix broken unit test * feat: setup android unit tests for verifying storage behaviours and state of shared configs * feat: adding dependencies to try and get android tests working, fixing bug with initial config not syncing properly * fix: remove hilt testing, add spy on app context storage field instead, update libsession-util to fixed sodium cmake branch * refactor: use PR version of libsession-util to test cmake build * fix: new build on normal repo * feat: new libsession util commit * refactor: remove the old custom build libsodium stuff from cmake * feat: update libsession module * fix: add legacy config subscription to the home activity to enable showing banner at any time * fix: pinned status for communities and groups, group last read time being set to snodeapi.now on finish joining * fix: some open group volatile convo fix for last read timer being set. Need to investigate further * fix: prevent blocking local number * fix: adding in more checks for open group inbox recipients before being saved to the shared configs. Prevent sending typing indicator for blocked users * fix: add blocked check for read receipt and updating expiring messages * fix: another contact recipient config library call removed for non-standard IDs * fix: another ID check * fix: don't process thread creation for user is sender && recipient (sync message) for message request responses * refactor: mark as read on open and use less buffer time * fix: finally fix the darn unread count issue by * fix: removing debug logs, adding failure error handling logs for expiry message updater, properly using the message thread ID created for the expiring messages. Process the non-thread messages properly with await in BatchMessageReceiveJob * fix: checking the last read open to message and make sure that scroll behaviour matches expected, fix the config sync job not deleting ALL old hashes only latest * refactor: try to add a retry logic to config sync job in case of snode failure * build: update submodule * fix: remove user notifications for leaving group to prevent synced device issues, don't create thread in messages for new closed groups, includei nactive groups in the deletion queries for merging group configs * feat: use blinded message count for banner also * refactor: remove some logging, don't use blinded conversations in the list * fix: don't set the read flag in update notifications, some roundabout logic for first loads and scrolling to last known positions * refactor: merge changes, re-add the group check in unapproved messages * fix: re-poll on fail in case that was breaking anything * fix: pinning groups and notifying list listeners in threadDb.setPinned * feat: add in TTL extension subrequest and builder, enable extending TTLs for all latest config messages in poll as subrequest * feat: add block to the delete all message requests, only if they're not open group inbox contacts * refactor: disable edit text for non contacts * refactor: let the user display name return "You" for local user * fix: prevent NTS self create thread on user view bind * refactor: remove populate public key cache if needed call which seems unnecessary at that point, maybe UserView refs have changed since 2020 * refactor: use just first visible instead of completely visible, merge message sender changes * fix: prevent block of users in delete all * fix: self sync sync message failures for default values * feat: update libsession-util, adjust docs, update mms and sms to use message sent timestamp instead of -1 for last read in the thread * fix: some compile issues in tests and some TODOs for things to do before merge * fix: handle recyclerview scrolled on scroll to first unread if it's the first load * fix: added more migration code for deleting unnecessary threads and groups, fixed a post-migration last seen issue on last item (current read is now), comment out actual network sync while testing migrations * feat: adding a force new configs flag and logic for timestamp handling / forced configs, fix issue with handling legacy messages * refactor: re-add the sending of configs * fix: don't add contacts if they don't exist in the profile manager * [wip] fix: trying to consolidate prof pic and key properly * feat: add logs and fix compile issue with a themes.xml entry, add removing profile picture into logic for profile manager * fix: force has sent for local user, only prevent setting last seen for open group recipients, allow empty user pics to trigger config sync in settings * fix: nts threads * fix: open group avatar loop for open groups we have left * feat: add a wrapper hash to track home diff util changes for wrapper contact recipient info, add test for dirty state in double set * feat: add a dump in there as well * refactor: more test code refactor * fix: update last seen if later than current * fix: open group threads and avatar downloads * fix: add max size and maybe fix the non-200 sub requests for batches (for 421s in particular) * fix: open group comparison issues potentially, have to update some more outgoing message open group flags for visibility of details etc * Updated to the latest libSession-util * Updated logic to delete legacy groups when kicked/left * Added the legacy group 'joined_at' value * Replaced incorrect character in JNI * Fixed an issue where the group keyPair was getting encoded incorrectly * Updated the code to ignore outdated legacy group control message changes * Updated the code to ignore messages invalidated by the config * [Review] Updated the poller to process config messages before standard * Cleaned up the outdated message logic * Fixed inverted config dropping flags * Fixed an issue where the joining a community would read all messages Stopped using a reversed RecyclerView in all cases (caused the unread issue) Updated the logic to jump to the newly sent message when sending a message (to be consistent with other platforms) Updated the logic to refresh the DB unread count when the cursor receives an update * Updated the conversation to highlight the first unread message on open * Fixed a couple of bugs with the highlighting * Fixed a bug where the user profile picture wasn't downloading correctly * feat: add all namespaces to delete all messages request and signature verification data * fix: merge namespace hashes for signature returned and * fix: import correct scroll to bottom * build: update version code and name * fix: initial contact generation fix for existing blinded contacts * fix: initial convo generation fix for existing blinded convos (?) * fix: conversation unread not doing a check for standard ID prefix * fix: thread ID not being created for legacy config messages * fix: don't treat 404 as bad snode * fix: don't add retrieve profile job if we have one for that address * build: update build code * fix: reduce attempts for downloading image, invert unreachable type check * fix: attempting to fix preventing message processing if group thread is not active for closed groups and initial contact dump only allows conversations with thread, may need further optimisations though * feat: Added an unread marker and search result focus highlighting * fix: empty set in appropriate places for current closed groups * build: update build version code * fix: fix the notifications and request at appropriate time * refactor: remove debug logging for thread create and delete * build: update build number * fix: new community doesn't break persisting config if the .add request fails * build: trying to track down broken retrieve avatar job * feat: update to latest libsession dev * fix: maybe fix avatar download for new messages * fix: 404s causing snode errors and trying to retrieve avatars that have already 404'd a lot * fix: closed group creation sets thread date to formation timestamp * build: update version code * build: update version code * build: remove debuggable release build * fix: use new permissions for external attachments * build: update version code * chore: remove debug logs * fix: tests and main thread blocking db fetch for path status view * wip: trying to track down failure to mark conversation as read in delayed group add * wip: add more logs for initial last Read sync of communities * wip: maybe the volatile is being updated with 0 on batch message receive? * fix: maybe syncing read statuses are working now * chore: remove debug logs * build: update build number * fix: trying to improve performance * fix: add close to banner * refactor: hide seed reminder in preview * build: update build number * fix: maybe requires update thread no matter what * fix: message request banner shows again * fix: android tests work again and permissions * fix: blocked contacts click handler being overridden by something * Revert "fix: blocked contacts click handler being overridden by something" This reverts commit 608572fc426def0850085727ed0c399623110c37. * build: update build number * refactor: remove unused dependencies and update minor for sqlcipher * fix: actually do insert contact, because otherwise name doesn't get set properly * fix: maybe fix scroll to bottom issue * build: update build number * fix: the message time and jump to message queries are more optimized * fix: maybe fix the last seen issues * build: update build number * fix: pfp broken closed groups why * fix: add admins and members as member list instead of just members * fix: exclude lgc without membership > 1 and inactive explicitly * fix: submodule update * fix: compiles with removal of iterator erase * fix: unread indicator updates properly in ConversationActivityV2 * fix: unread notifications clear and altered if any notifications exist (prevents clearing read notifications in conversation or on home screen) * refactor: profile pictures kinda broken * build: update build number * refactor: remove full hash from log * fix: isPinned threadDB call * refactor: use mutex in all libsession native calls, change timestamp * refactor: add basic support for blinded v2 prefixes --------- Co-authored-by: Morgan Pretty --- .gitmodules | 3 + app/build.gradle | 56 +- .../loki/messenger/HomeActivityTests.kt | 5 + .../network/loki/messenger/LibSessionTests.kt | 97 ++ app/src/main/AndroidManifest.xml | 4 + .../securesms/ApplicationContext.java | 33 +- .../securesms/calls/WebRtcCallActivity.kt | 1 + .../components/ProfilePictureView.kt | 43 +- .../securesms/contacts/UserView.kt | 5 +- .../start/NewConversationHomeFragment.kt | 2 +- .../conversation/v2/ConversationActivityV2.kt | 310 ++++- .../conversation/v2/ConversationAdapter.kt | 53 +- .../conversation/v2/ConversationViewModel.kt | 43 +- .../conversation/v2/dialogs/BlockedDialog.kt | 7 +- .../v2/dialogs/JoinOpenGroupDialog.kt | 2 +- .../v2/menus/ConversationMenuHelper.kt | 21 +- .../v2/messages/VisibleMessageContentView.kt | 30 +- .../v2/messages/VisibleMessageView.kt | 12 +- .../v2/messages/VoiceMessageView.kt | 2 +- .../v2/utilities/AttachmentManager.java | 18 +- .../securesms/database/ConfigDatabase.kt | 53 + .../securesms/database/GroupDatabase.java | 8 +- .../securesms/database/LokiAPIDatabase.kt | 3 +- .../securesms/database/LokiThreadDatabase.kt | 16 +- .../securesms/database/MmsDatabase.kt | 95 +- .../securesms/database/MmsSmsDatabase.java | 26 +- .../securesms/database/RecipientDatabase.java | 43 +- .../database/SessionContactDatabase.kt | 7 +- .../securesms/database/SessionJobDatabase.kt | 17 +- .../securesms/database/SmsDatabase.java | 44 +- .../securesms/database/Storage.kt | 687 ++++++++- .../securesms/database/ThreadDatabase.java | 183 ++- .../database/helpers/SQLCipherOpenHelper.java | 21 +- .../securesms/dependencies/AppModule.kt | 7 + .../securesms/dependencies/ConfigFactory.kt | 251 ++++ .../dependencies/DatabaseComponent.kt | 1 + .../securesms/dependencies/DatabaseModule.kt | 11 +- .../dependencies/InjectableType.java | 4 - .../dependencies/SessionUtilModule.kt | 36 + .../securesms/dms/NewMessageFragment.kt | 2 +- .../securesms/groups/ClosedGroupManager.kt | 64 + .../groups/EditClosedGroupActivity.kt | 26 +- .../securesms/groups/GroupManager.java | 4 + .../securesms/groups/JoinCommunityFragment.kt | 4 +- .../securesms/groups/OpenGroupManager.kt | 34 +- .../home/ConversationOptionsBottomSheet.kt | 9 +- .../securesms/home/ConversationView.kt | 14 +- .../securesms/home/HomeActivity.kt | 90 +- .../securesms/home/HomeAdapter.kt | 4 +- .../securesms/home/HomeDiffUtil.kt | 9 +- .../securesms/home/PathStatusView.kt | 32 +- .../securesms/home/UserDetailsBottomSheet.kt | 19 +- .../home/search/GlobalSearchAdapterUtils.kt | 3 + .../keyboard/emoji/KeyboardPageSearchView.kt | 2 +- .../MessageRequestsActivity.kt | 6 +- .../messagerequests/MessageRequestsAdapter.kt | 1 + .../MessageRequestsViewModel.kt | 4 +- .../notifications/DefaultMessageNotifier.java | 23 +- .../notifications/MarkReadReceiver.java | 15 +- .../onboarding/LinkDeviceActivity.kt | 12 +- .../securesms/onboarding/PNModeActivity.kt | 1 + .../RecoveryPhraseRestoreActivity.kt | 9 + .../securesms/onboarding/RegisterActivity.kt | 9 + .../preferences/BlockedContactsActivity.kt | 2 +- .../preferences/BlockedContactsViewModel.kt | 8 +- .../CorrectedPreferenceFragment.java | 6 +- .../securesms/preferences/SettingsActivity.kt | 40 +- .../widgets/ColorPickerPreference.java | 251 ---- ...rPickerPreferenceDialogFragmentCompat.java | 64 - .../repository/ConversationRepository.kt | 29 +- .../service/ExpiringMessageManager.java | 42 +- .../sskenvironment/ProfileManager.kt | 86 +- .../securesms/util/CallNotificationBuilder.kt | 12 +- .../util/ConfigurationMessageUtilities.kt | 231 +++- .../securesms/util/DateUtils.java | 3 +- .../thoughtcrime/securesms/util/GlowView.kt | 68 +- .../securesms/util/MockDataGenerator.kt | 9 +- .../securesms/util/SessionMetaProtocol.kt | 4 +- .../securesms/util/SharedConfigUtils.kt | 22 + .../securesms/util/ViewUtilities.kt | 2 +- .../securesms/webrtc/CallManager.kt | 20 +- .../main/res/color/prominent_button_color.xml | 2 +- .../res/layout/activity_conversation_v2.xml | 13 + app/src/main/res/layout/activity_home.xml | 61 +- .../fragment_user_details_bottom_sheet.xml | 1 + .../main/res/layout/view_profile_picture.xml | 4 + .../main/res/layout/view_visible_message.xml | 40 + .../layout/view_visible_message_content.xml | 4 +- app/src/main/res/values/strings.xml | 8 + app/src/main/res/values/themes.xml | 80 ++ .../res/xml/preferences_app_protection.xml | 1 - .../v2/ConversationViewModelTest.kt | 3 +- .../MessageRequestsViewModelTest.kt | 4 +- .../recipients/RecipientExporterTest.java | 2 +- build.gradle | 4 +- gradle.properties | 52 +- libsession-util/.gitignore | 2 + libsession-util/build.gradle | 47 + libsession-util/libsession-util | 1 + .../libsession_util/InstrumentedTests.kt | 584 ++++++++ libsession-util/src/main/AndroidManifest.xml | 5 + libsession-util/src/main/cpp/CMakeLists.txt | 66 + libsession-util/src/main/cpp/config_base.cpp | 154 +++ libsession-util/src/main/cpp/config_base.h | 28 + libsession-util/src/main/cpp/contacts.cpp | 100 ++ libsession-util/src/main/cpp/contacts.h | 109 ++ libsession-util/src/main/cpp/conversation.cpp | 352 +++++ libsession-util/src/main/cpp/conversation.h | 122 ++ libsession-util/src/main/cpp/user_groups.cpp | 273 ++++ libsession-util/src/main/cpp/user_groups.h | 139 ++ libsession-util/src/main/cpp/user_profile.cpp | 98 ++ libsession-util/src/main/cpp/user_profile.h | 14 + libsession-util/src/main/cpp/util.cpp | 167 +++ libsession-util/src/main/cpp/util.h | 24 + .../loki/messenger/libsession_util/Config.kt | 200 +++ .../libsession_util/util/BaseCommunity.kt | 11 + .../messenger/libsession_util/util/Contact.kt | 13 + .../libsession_util/util/Conversation.kt | 25 + .../libsession_util/util/ExpiryMode.kt | 7 + .../libsession_util/util/GroupInfo.kt | 53 + .../messenger/libsession_util/util/Sodium.kt | 9 + .../messenger/libsession_util/util/Utils.kt | 67 + .../libsession_util/ExampleUnitTest.kt | 14 + libsession/build.gradle | 6 +- .../avatars/ResourceContactPhoto.java | 2 +- .../libsession/database/StorageProtocol.kt | 43 +- .../messaging/MessagingModuleConfiguration.kt | 4 +- .../messaging/jobs/AttachmentDownloadJob.kt | 2 +- .../messaging/jobs/AttachmentUploadJob.kt | 8 +- .../messaging/jobs/BackgroundGroupAddJob.kt | 7 +- .../messaging/jobs/BatchMessageReceiveJob.kt | 184 ++- .../messaging/jobs/ConfigurationSyncJob.kt | 206 +++ .../messaging/jobs/GroupAvatarDownloadJob.kt | 10 +- .../session/libsession/messaging/jobs/Job.kt | 2 +- .../libsession/messaging/jobs/JobQueue.kt | 5 +- .../messaging/jobs/MessageReceiveJob.kt | 13 +- .../messaging/jobs/MessageSendJob.kt | 6 +- .../messaging/jobs/NotifyPNServerJob.kt | 8 +- .../messaging/jobs/OpenGroupDeleteJob.kt | 2 +- .../jobs/RetrieveProfileAvatarJob.kt | 50 +- .../jobs/SessionJobManagerFactories.kt | 1 + .../messaging/jobs/TrimThreadJob.kt | 2 +- .../libsession/messaging/messages/Message.kt | 15 + .../messages/control/ConfigurationMessage.kt | 4 +- .../control/SharedConfigurationMessage.kt | 36 + .../sending_receiving/MessageReceiver.kt | 17 +- .../sending_receiving/MessageSender.kt | 183 +-- .../MessageSenderClosedGroupHandler.kt | 47 +- .../ReceivedMessageHandler.kt | 225 ++- .../pollers/OpenGroupPoller.kt | 10 +- .../sending_receiving/pollers/Poller.kt | 177 ++- .../utilities/UpdateMessageBuilder.kt | 1 + .../libsession/snode/OnionRequestAPI.kt | 4 +- .../org/session/libsession/snode/SnodeAPI.kt | 295 +++- .../session/libsession/utilities/Address.kt | 10 +- .../utilities/ConfigFactoryProtocol.kt | 23 + .../libsession/utilities/DownloadUtilities.kt | 6 +- .../session/libsession/utilities/GroupUtil.kt | 26 +- .../libsession/utilities/ProfileKeyUtil.java | 9 +- .../libsession/utilities/SSKEnvironment.kt | 8 +- .../utilities/TextSecurePreferences.kt | 36 +- .../utilities/recipients/Recipient.java | 38 +- .../recipients/RecipientProvider.java | 2 + libsession/src/main/res/values/attrs.xml | 12 - ...arserTest.kt => CommunityUrlParserTest.kt} | 4 +- libsignal/protobuf/SignalService.proto | 20 + .../libsignal/protos/SignalServiceProtos.java | 1230 +++++++++++++++-- .../session/libsignal/utilities/IdPrefix.kt | 5 +- .../session/libsignal/utilities/Namespace.kt | 2 +- .../org/session/libsignal/utilities/Snode.kt | 8 +- settings.gradle | 3 +- 171 files changed, 8087 insertions(+), 1448 deletions(-) create mode 100644 .gitmodules create mode 100644 app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt create mode 100644 libsession-util/.gitignore create mode 100644 libsession-util/build.gradle create mode 160000 libsession-util/libsession-util create mode 100644 libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt create mode 100644 libsession-util/src/main/AndroidManifest.xml create mode 100644 libsession-util/src/main/cpp/CMakeLists.txt create mode 100644 libsession-util/src/main/cpp/config_base.cpp create mode 100644 libsession-util/src/main/cpp/config_base.h create mode 100644 libsession-util/src/main/cpp/contacts.cpp create mode 100644 libsession-util/src/main/cpp/contacts.h create mode 100644 libsession-util/src/main/cpp/conversation.cpp create mode 100644 libsession-util/src/main/cpp/conversation.h create mode 100644 libsession-util/src/main/cpp/user_groups.cpp create mode 100644 libsession-util/src/main/cpp/user_groups.h create mode 100644 libsession-util/src/main/cpp/user_profile.cpp create mode 100644 libsession-util/src/main/cpp/user_profile.h create mode 100644 libsession-util/src/main/cpp/util.cpp create mode 100644 libsession-util/src/main/cpp/util.h create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt create mode 100644 libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt rename libsession/src/test/java/org/session/libsession/utilities/{OpenGroupUrlParserTest.kt => CommunityUrlParserTest.kt} (98%) diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..b650b98b11 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libsession-util/libsession-util"] + path = libsession-util/libsession-util + url = https://github.com/oxen-io/libsession-util.git diff --git a/app/build.gradle b/app/build.gradle index 8a42419732..2fbac71054 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -93,17 +93,13 @@ dependencies { exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' } implementation 'com.annimon:stream:1.1.8' - implementation 'com.takisoft.fix:colorpicker:1.0.1' implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' - implementation 'androidx.sqlite:sqlite-ktx:2.2.0' - implementation 'net.zetetic:sqlcipher-android:4.5.3@aar' - implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') { - exclude group: 'com.fasterxml.jackson.core' - exclude group: 'org.freemarker' - } + implementation 'androidx.sqlite:sqlite-ktx:2.3.1' + implementation 'net.zetetic:sqlcipher-android:4.5.4@aar' implementation project(":libsignal") implementation project(":libsession") + implementation project(":libsession-util") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation project(":liblazysodium") @@ -116,52 +112,52 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" - implementation "com.github.lelloman:android-identicons:v11" - implementation "com.prof.rssparser:rssparser:2.0.4" implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0" implementation "com.github.tbruyelle:rxpermissions:0.10.2" implementation "com.github.ybq:Android-SpinKit:1.4.0" implementation "com.opencsv:opencsv:4.6" testImplementation "junit:junit:$junitVersion" testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.0.0" + testImplementation "org.mockito:mockito-inline:4.10.0" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation 'org.powermock:powermock-api-mockito:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1' - testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' + androidTestImplementation "org.mockito:mockito-android:4.10.0" + androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "androidx.test:core:$testCoreVersion" - testImplementation "androidx.arch.core:core-testing:2.1.0" + testImplementation "androidx.arch.core:core-testing:2.2.0" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" // Core library - androidTestImplementation 'androidx.test:core:1.4.0' + androidTestImplementation "androidx.test:core:$testCoreVersion" + + androidTestImplementation('com.adevinta.android:barista:4.2.0') { + exclude group: 'org.jetbrains.kotlin' + } // AndroidJUnitRunner and JUnit Rules - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' // Assertions - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.ext:truth:1.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.ext:truth:1.5.0' androidTestImplementation 'com.google.truth:truth:1.1.3' // Espresso dependencies - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0' - androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' - androidTestUtil 'androidx.test:orchestrator:1.4.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' + androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' + androidTestUtil 'androidx.test:orchestrator:1.4.2' testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4' } -def canonicalVersionCode = 338 -def canonicalVersionName = "1.16.9" +def canonicalVersionCode = 353 +def canonicalVersionName = "1.17.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 087d486893..eabe06f7d9 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -1,5 +1,6 @@ package network.loki.messenger +import android.Manifest import android.app.Instrumentation import android.content.ClipboardManager import android.content.Context @@ -21,6 +22,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import com.adevinta.android.barista.interaction.PermissionGranter import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf @@ -85,6 +87,8 @@ class HomeActivityTests { } onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click()) onView(withId(R.id.registerButton)).perform(ViewActions.click()) + // allow notification permission + PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) } private fun goToMyChat() { @@ -100,6 +104,7 @@ class HomeActivityTests { copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString() } onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied)) + onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) } diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt new file mode 100644 index 0000000000..1638d83835 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -0,0 +1,97 @@ +package network.loki.messenger + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.argThat +import org.mockito.kotlin.eq +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.KeyHelper +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import kotlin.random.Random + +@RunWith(AndroidJUnit4::class) +@SmallTest +class LibSessionTests { + + private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() } + private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray()) + private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey + + private var fakeHashI = 0 + private val nextFakeHash: String + get() = "fakehash${fakeHashI++}" + + private fun maybeGetUserInfo(): Pair? { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val prefs = appContext.prefs + val localUserPublicKey = prefs.getLocalNumber() + val secretKey = with(appContext) { + val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null + edKey.secretKey.asBytes + } + return if (localUserPublicKey == null || secretKey == null) null + else secretKey to localUserPublicKey + } + + private fun buildContactMessage(contactList: List): ByteArray { + val (key,_) = maybeGetUserInfo()!! + val contacts = Contacts.Companion.newInstance(key) + contactList.forEach { contact -> + contacts.set(contact) + } + return contacts.push().config + } + + private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) { + configBase.merge(nextFakeHash to toMerge) + MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis()) + } + + @Before + fun setupUser() { + val newBytes = randomSeedBytes().toByteArray() + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + val kp = KeyPairUtilities.generate(newBytes) + KeyPairUtilities.store(context, kp.seed, kp.ed25519KeyPair, kp.x25519KeyPair) + val registrationID = KeyHelper.generateRegistrationId(false) + TextSecurePreferences.setLocalRegistrationId(context, registrationID) + TextSecurePreferences.setLocalNumber(context, kp.x25519KeyPair.hexEncodedPublicKey) + TextSecurePreferences.setRestorationTime(context, 0) + TextSecurePreferences.setHasViewedSeed(context, false) + } + + @Test + fun migration_one_to_ones() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + val newContactId = randomSessionId() + val singleContact = Contact( + id = newContactId, + approved = true, + expiryMode = ExpiryMode.NONE + ) + val newContactMerge = buildContactMessage(listOf(singleContact)) + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + fakePollNewConfig(contacts, newContactMerge) + verify(storageSpy).addLibSessionContacts(argThat { + first().let { it.id == newContactId && it.approved } && size == 1 + }) + verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d2b2123dd..aa81fafc2b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,12 +29,16 @@ android:name="android.hardware.touchscreen" android:required="false" /> + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 53141534af..e4be27f24b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -40,6 +40,7 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol import org.session.libsession.messaging.sending_receiving.pollers.Poller; import org.session.libsession.snode.SnodeModule; import org.session.libsession.utilities.Address; +import org.session.libsession.utilities.ConfigFactoryUpdateListener; import org.session.libsession.utilities.ProfilePictureUtilities; import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.TextSecurePreferences; @@ -59,6 +60,8 @@ import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.EmojiSearchData; +import org.thoughtcrime.securesms.dependencies.AppComponent; +import org.thoughtcrime.securesms.dependencies.ConfigFactory; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.emoji.EmojiSource; @@ -106,6 +109,8 @@ import dagger.hilt.EntryPoints; import dagger.hilt.android.HiltAndroidApp; import kotlin.Unit; import kotlinx.coroutines.Job; +import network.loki.messenger.libsession_util.ConfigBase; +import network.loki.messenger.libsession_util.UserProfile; /** * Will be called once when the TextSecure process is created. @@ -116,7 +121,7 @@ import kotlinx.coroutines.Job; * @author Moxie Marlinspike */ @HiltAndroidApp -public class ApplicationContext extends Application implements DefaultLifecycleObserver { +public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener { public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; @@ -137,9 +142,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO private PersistentLogger persistentLogger; @Inject LokiAPIDatabase lokiAPIDatabase; - @Inject Storage storage; + @Inject public Storage storage; @Inject MessageDataProvider messageDataProvider; @Inject TextSecurePreferences textSecurePreferences; + @Inject ConfigFactory configFactory; CallMessageProcessor callMessageProcessor; MessagingModuleConfiguration messagingModuleConfiguration; @@ -157,6 +163,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO return (ApplicationContext) context.getApplicationContext(); } + public TextSecurePreferences getPrefs() { + return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs(); + } + public DatabaseComponent getDatabaseComponent() { return EntryPoints.get(getApplicationContext(), DatabaseComponent.class); } @@ -183,6 +193,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO return this.persistentLogger; } + @Override + public void notifyUpdates(@NonNull ConfigBase forConfigObject) { + // forward to the config factory / storage ig + if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) { + textSecurePreferences.setConfigurationMessageSynced(true); + } + storage.notifyConfigUpdates(forConfigObject); + } + @Override public void onCreate() { DatabaseModule.init(this); @@ -191,7 +210,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO messagingModuleConfiguration = new MessagingModuleConfiguration(this, storage, messageDataProvider, - ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this)); + ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), + configFactory + ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); startKovenant(); @@ -347,7 +368,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } private void initializeProfileManager() { - this.profileManager = new ProfileManager(); + this.profileManager = new ProfileManager(this, configFactory); } private void initializeTypingStatusSender() { @@ -440,7 +461,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.setUserPublicKey(userPublicKey); return; } - poller = new Poller(); + poller = new Poller(configFactory, new Timer()); } public void startPollingIfNeeded() { @@ -483,6 +504,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO }); } catch (Exception exception) { // Do nothing + Log.e("Loki-Avatar", "Uploading avatar failed", exception); } }); } @@ -520,6 +542,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { Log.d("Loki", "Failed to delete database."); } + configFactory.keyPairChanged(); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index 7e732d1aa7..ded6b4f140 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -260,6 +260,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { CALL_CONNECTED -> { wantsToAnswer = false } + else -> { /* do nothing */ } } updateControls(state) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index a827a7d260..0101677d89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -32,10 +32,10 @@ class ProfilePictureView @JvmOverloads constructor( var isLarge = false private val profilePicturesCache = mutableMapOf() - private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) - private val unknownOpenGroupDrawable = ResourceContactPhoto(R.drawable.ic_notification) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) + private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } + private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } // endregion @@ -52,12 +52,19 @@ class ProfilePictureView @JvmOverloads constructor( .sorted() .take(2) .toMutableList() - val pk = members.getOrNull(0)?.serialize() ?: "" - publicKey = pk - displayName = getUserDisplayName(pk) - val apk = members.getOrNull(1)?.serialize() ?: "" - additionalPublicKey = apk - additionalDisplayName = getUserDisplayName(apk) + if (members.size <= 1) { + publicKey = "" + displayName = "" + additionalPublicKey = "" + additionalDisplayName = "" + } else { + val pk = members.getOrNull(0)?.serialize() ?: "" + publicKey = pk + displayName = getUserDisplayName(pk) + val apk = members.getOrNull(1)?.serialize() ?: "" + additionalPublicKey = apk + additionalDisplayName = getUserDisplayName(apk) + } } else if(recipient.isOpenGroupInboxRecipient) { val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize()) this.publicKey = publicKey @@ -108,30 +115,36 @@ class ProfilePictureView @JvmOverloads constructor( val signalProfilePicture = recipient.contactPhoto val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject + val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") + if (signalProfilePicture != null && avatar != "0" && avatar != "") { glide.clear(imageView) glide.load(signalProfilePicture) .placeholder(unknownRecipientDrawable) .centerCrop() - .error(unknownRecipientDrawable) + .error(glide.load(placeholder)) .diskCacheStrategy(DiskCacheStrategy.NONE) .circleCrop() .into(imageView) } else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) { glide.clear(imageView) - imageView.setImageDrawable(unknownOpenGroupDrawable) + glide.load(unknownOpenGroupDrawable) + .centerCrop() + .circleCrop() + .into(imageView) } else { - val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") - glide.clear(imageView) glide.load(placeholder) .placeholder(unknownRecipientDrawable) .centerCrop() + .circleCrop() .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) } profilePicturesCache[publicKey] = recipient.profileAvatar } else { - imageView.setImageDrawable(null) + glide.load(unknownRecipientDrawable) + .centerCrop() + .into(imageView) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index e88cf1d08b..72c9749f9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -7,6 +7,7 @@ import android.view.View import android.widget.LinearLayout import network.loki.messenger.R import network.loki.messenger.databinding.ViewUserBinding +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities @@ -47,12 +48,12 @@ class UserView : LinearLayout { // region Updating fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) { + val isLocalUser = user.isLocalNumber fun getUserDisplayName(publicKey: String): String { + if (isLocalUser) return context.getString(R.string.MessageRecord_you) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } - val threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(user) - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, context) // FIXME: This is a bad place to do this val address = user.address.serialize() binding.profilePictureView.root.glide = glide binding.profilePictureView.root.update(user) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt index 2e62932ab0..92f050f76a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt @@ -55,7 +55,7 @@ class NewConversationHomeFragment : Fragment() { val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId ContactListItem.Contact(it, displayName) }.sortedBy { it.displayName } - .groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.first().uppercase() } + .groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle } .toMutableMap() contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) } adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index b419658e78..7b742a9be9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -3,28 +3,50 @@ package org.thoughtcrime.securesms.conversation.v2 import android.Manifest import android.animation.FloatEvaluator import android.animation.ValueAnimator -import android.content.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.DialogInterface +import android.content.Intent import android.content.res.Resources import android.database.Cursor import android.graphics.Rect import android.graphics.Typeface import android.net.Uri -import android.os.* +import android.os.AsyncTask +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.provider.MediaStore +import android.text.SpannableStringBuilder +import android.text.SpannedString import android.text.TextUtils +import android.text.style.StyleSpan import android.util.Pair import android.util.TypedValue -import android.view.* +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.Toast import androidx.activity.viewModels import androidx.annotation.DimenRes +import androidx.appcompat.app.AlertDialog +import androidx.core.text.set +import androidx.core.text.toSpannable +import androidx.core.view.drawToBitmap import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager @@ -32,6 +54,11 @@ import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R @@ -58,8 +85,12 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.* +import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.Stub +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientModifiedListener @@ -91,10 +122,25 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel -import org.thoughtcrime.securesms.conversation.v2.utilities.* +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities +import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import org.thoughtcrime.securesms.database.* +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.LokiAPIDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReactionDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord @@ -107,16 +153,31 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivity -import org.thoughtcrime.securesms.mms.* +import org.thoughtcrime.securesms.mms.AudioSlide +import org.thoughtcrime.securesms.mms.GifSlide +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.mms.ImageSlide +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showExpirationDialog import org.thoughtcrime.securesms.showSessionDialog -import org.thoughtcrime.securesms.util.* +import org.thoughtcrime.securesms.util.ActivityDispatcher +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.SaveAttachmentTask +import org.thoughtcrime.securesms.util.isScrolledToBottom +import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.toPx import java.lang.ref.WeakReference -import java.util.* +import java.util.Locale import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -185,11 +246,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe it } val recipient = Recipient.from(this, address, false) - threadId = threadDb.getOrCreateThreadIdFor(recipient) + threadId = storage.getOrCreateThreadIdFor(recipient.address) } } ?: finish() } - viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) + viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver) } private var actionMode: ActionMode? = null private var unreadCount = 0 @@ -210,6 +271,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null + private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) private var emojiPickerVisible = false private val isScrolledToBottom: Boolean @@ -229,11 +291,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) } + // There is a bug when initially joining a community where all messages will immediately be marked + // as read if we reverse the message list so this is now hard-coded to false + private val reverseMessageList = false + private val adapter by lazy { - val cursor = mmsSmsDb.getConversation(viewModel.threadId, !isIncomingMessageRequestThread()) + val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList) val adapter = ConversationAdapter( this, cursor, + storage.getLastSeen(viewModel.threadId), + reverseMessageList, onItemPress = { message, position, view, event -> handlePress(message, position, view, event) }, @@ -275,6 +343,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollAuthor = AtomicReference(null) + private val firstLoad = AtomicBoolean(true) private lateinit var reactionDelegate: ConversationReactionDelegate private val reactWithAnyEmojiStartPage = -1 @@ -319,28 +388,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpUiStateObserver() binding!!.scrollToBottomButton.setOnClickListener { val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener + val targetPosition = if (reverseMessageList) 0 else adapter.itemCount if (layoutManager.isSmoothScrolling) { - binding?.conversationRecyclerView?.scrollToPosition(0) + binding?.conversationRecyclerView?.scrollToPosition(targetPosition) } else { // It looks like 'smoothScrollToPosition' will actually load all intermediate items in // order to do the scroll, this can be very slow if there are a lot of messages so // instead we check the current position and if there are more than 10 items to scroll // we jump instantly to the 10th item and scroll from there (this should happen quick // enough to give a similar scroll effect without having to load everything) - val position = layoutManager.findFirstVisibleItemPosition() - if (position > 10) { - binding?.conversationRecyclerView?.scrollToPosition(10) - } +// val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition() +// val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10) +// if (position > targetBuffer) { +// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer) +// } binding?.conversationRecyclerView?.post { - binding?.conversationRecyclerView?.smoothScrollToPosition(0) + binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition) } } } updateUnreadCountIndicator() updateSubtitle() + updatePlaceholder() setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) updateSendAfterApprovalText() @@ -350,20 +422,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val weakActivity = WeakReference(this) lifecycleScope.launch(Dispatchers.IO) { - unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) - // Note: We are accessing the `adapter` property because we want it to be loaded on // the background thread to avoid blocking the UI thread and potentially hanging when // transitioning to the activity weakActivity.get()?.adapter ?: return@launch + // 'Get' instead of 'GetAndSet' here because we want to trigger the highlight in 'onFirstLoad' + // by triggering 'jumpToMessage' using these values + val messageTimestamp = messageToScrollTimestamp.get() + val author = messageToScrollAuthor.get() + val targetPosition = if (author != null && messageTimestamp >= 0) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, messageTimestamp, author, reverseMessageList) else -1 + withContext(Dispatchers.Main) { setUpRecyclerView() setUpTypingObserver() setUpRecipientObserver() getLatestOpenGroupInfoIfNeeded() setUpSearchResultObserver() - scrollToFirstUnreadMessageIfNeeded() + + if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { + binding?.conversationRecyclerView?.scrollToPosition(targetPosition) + } + else { + scrollToFirstUnreadMessageIfNeeded(true) + } } } @@ -371,16 +453,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate.setOnReactionSelectedListener(this) + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + // only update the conversation every 3 seconds maximum + // channel is rendezvous and shouldn't block on try send calls as often as we want + val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow() + bufferedFlow.filter { + it > storage.getLastSeen(viewModel.threadId) + }.collectLatest { latestMessageRead -> + withContext(Dispatchers.IO) { + storage.markConversationAsRead(viewModel.threadId, latestMessageRead) + } + } + } + } } override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) - val recipient = viewModel.recipient ?: return - - lifecycleScope.launch(Dispatchers.IO) { - threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient) - } contentResolver.registerContentObserver( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, @@ -412,18 +503,40 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return ConversationLoader(viewModel.threadId, !isIncomingMessageRequestThread(), this@ConversationActivityV2) + return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2) } override fun onLoadFinished(loader: Loader, cursor: Cursor?) { + val oldCount = adapter.itemCount + val newCount = cursor?.count ?: 0 adapter.changeCursor(cursor) + if (cursor != null) { val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val author = messageToScrollAuthor.getAndSet(null) + val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) + + // Update the unreadCount value to be loaded from the database since we got a new message + if (firstLoad.get() || oldCount != newCount || initialUnreadCount != unreadCount) { + // Update the unreadCount value to be loaded from the database since we got a new + // message (we need to store it in a local variable as it can get overwritten on + // another thread before the 'firstLoad.getAndSet(false)' case below) + unreadCount = initialUnreadCount + updateUnreadCountIndicator() + } + if (author != null && messageTimestamp >= 0) { - jumpToMessage(author, messageTimestamp, null) + jumpToMessage(author, messageTimestamp, firstLoad.get(), null) + } + else if (firstLoad.getAndSet(false)) { + scrollToFirstUnreadMessageIfNeeded(true) + handleRecyclerViewScrolled() + } + else if (oldCount != newCount) { + handleRecyclerViewScrolled() } } + updatePlaceholder() } override fun onLoaderReset(cursor: Loader) { @@ -433,7 +546,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpRecyclerView() { binding!!.conversationRecyclerView.adapter = adapter - val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, !isIncomingMessageRequestThread()) + val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList) binding!!.conversationRecyclerView.layoutManager = layoutManager // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) @@ -442,6 +555,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { handleRecyclerViewScrolled() } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + + } }) binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> @@ -577,7 +694,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBanner?.isVisible = recipient.isBlocked - binding?.blockedBanner?.setOnClickListener { viewModel.unblock(this@ConversationActivityV2) } + binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } } private fun setUpLinkPreviewObserver() { @@ -610,15 +727,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (uiState.isMessageRequestAccepted == true) { binding?.messageRequestBar?.visibility = View.GONE } + if (!uiState.conversationExists && !isFinishing) { + // Conversation should be deleted now, just go back + finish() + } } } } - private fun scrollToFirstUnreadMessageIfNeeded() { + private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int { val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() - val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return - if (lastSeenItemPosition <= 3) { return } + val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1 + + // If this is triggered when first opening a conversation then we want to position the top + // of the first unread message in the middle of the screen + if (isFirstLoad && !reverseMessageList) { + layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) + + if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) } + + return lastSeenItemPosition + } + + if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) + return lastSeenItemPosition + } + + private fun highlightViewAtPosition(position: Int) { + binding?.conversationRecyclerView?.post { + (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() + } } override fun onPrepareOptionsMenu(menu: Menu): Boolean { @@ -702,11 +841,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun acceptMessageRequest() { binding?.messageRequestBar?.isVisible = false - binding?.conversationRecyclerView?.layoutManager = - LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true) - adapter.notifyDataSetChanged() viewModel.acceptMessageRequest() - LoaderManager.getInstance(this).restartLoader(0, null, this) + lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) } @@ -904,17 +1040,60 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun handleRecyclerViewScrolled() { - // FIXME: Checking isScrolledToBottom is a quick fix for an issue where the - // typing indicator overlays the recycler view when scrolled up val binding = binding ?: return val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom showScrollToBottomButtonIfApplicable() - val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1 - unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0) + val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() + val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION + if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { + val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition) + if (visibleItemTimestamp != null) { + bufferedLastSeenChannel.trySend(visibleItemTimestamp) + } + } + + if (reverseMessageList) { + unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0) + } + else { + val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } + ?: RecyclerView.NO_POSITION + unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) + } updateUnreadCountIndicator() } + private fun updatePlaceholder() { + val recipient = viewModel.recipient + ?: return Log.w("Loki", "recipient was null in placeholder update") + val binding = binding ?: return + val openGroup = viewModel.openGroup + val (textResource, insertParam) = when { + recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null + openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString() + else -> R.string.activity_conversation_empty_state_default to recipient.toShortString() + } + val showPlaceholder = adapter.itemCount == 0 + binding.placeholderText.isVisible = showPlaceholder + if (showPlaceholder) { + if (insertParam != null) { + val span = getText(textResource) as SpannedString + val annotations = span.getSpans(0, span.length, StyleSpan::class.java) + val boldSpan = annotations.first() + val spannedParam = insertParam.toSpannable() + spannedParam[0 until spannedParam.length] = StyleSpan(boldSpan.style) + val originalStart = span.getSpanStart(boldSpan) + val originalEnd = span.getSpanEnd(boldSpan) + val newString = SpannableStringBuilder(span) + .replace(originalStart, originalEnd, spannedParam) + binding.placeholderText.text = newString + } else { + binding.placeholderText.setText(textResource) + } + } + } + private fun showScrollToBottomButtonIfApplicable() { binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 } @@ -970,7 +1149,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe title(R.string.RecipientPreferenceActivity_block_this_contact_question) text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { - viewModel.block(this@ConversationActivityV2) + viewModel.block() if (deleteThread) { viewModel.deleteThread() finish() @@ -1005,7 +1184,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (group?.isActive == false) { return } } showExpirationDialog(thread.expireMessages) { expirationTime -> - recipientDb.setExpireMessages(thread, expirationTime) + storage.setExpirationTimer(thread.address.serialize(), expirationTime) val message = ExpirationTimerUpdate(expirationTime) message.recipient = thread.address.serialize() message.sentTimestamp = SnodeAPI.nowWithOffset @@ -1022,7 +1201,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe destructiveButton( R.string.ConversationActivity_unblock, R.string.AccessibilityId_block_confirm - ) { viewModel.unblock(this@ConversationActivityV2) } + ) { viewModel.unblock() } cancelButton() } } @@ -1368,11 +1547,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return } val binding = binding ?: return - if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { + val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) } else { sendTextOnlyMessage() } + + // Jump to the newly sent message once it gets added + if (sentMessageInfo != null) { + messageToScrollAuthor.set(sentMessageInfo.first) + messageToScrollTimestamp.set(sentMessageInfo.second) + } } override fun commitInputContent(contentUri: Uri) { @@ -1390,19 +1575,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { - val recipient = viewModel.recipient ?: return + private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { + val recipient = viewModel.recipient ?: return null + val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { val dialog = SendSeedDialog { sendTextOnlyMessage(true) } - return dialog.show(supportFragmentManager, "Send Seed Dialog") + dialog.show(supportFragmentManager, "Send Seed Dialog") + return null } // Create the message val message = VisibleMessage() - message.sentTimestamp = SnodeAPI.nowWithOffset + message.sentTimestamp = sentTimestamp message.text = text val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) // Clear the input bar @@ -1419,14 +1606,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe MessageSender.send(message, recipient.address) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + return Pair(recipient.address, sentTimestamp) } - private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { - val recipient = viewModel.recipient ?: return + private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair? { + val recipient = viewModel.recipient ?: return null + val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() // Create the message val message = VisibleMessage() - message.sentTimestamp = SnodeAPI.nowWithOffset + message.sentTimestamp = sentTimestamp message.text = body val quote = quotedMessage?.let { val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() @@ -1460,6 +1649,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe MessageSender.send(message, recipient.address, attachments, quote, linkPreview) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + return Pair(recipient.address, sentTimestamp) } private fun showGIFPicker() { @@ -1829,7 +2019,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (result == null) return@Observer if (result.getResults().isNotEmpty()) { result.getResults()[result.position]?.let { - jumpToMessage(it.messageRecipient.address, it.sentTimestampMs) { + jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) { searchViewModel.onMissingResult() } } } @@ -1866,15 +2056,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe this.searchViewModel.onMoveDown() } - private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) { + private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) { SimpleTask.run(lifecycle, { - mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author) - }) { p: Int -> moveToMessagePosition(p, onMessageNotFound) } + mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList) + }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) } } - private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) { + private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) { if (position >= 0) { binding?.conversationRecyclerView?.scrollToPosition(position) + + if (highlight) { + runOnUiThread { + highlightViewAtPosition(position) + } + } } else { onMessageNotFound?.run() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index ca7e1de780..6013af5ba4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -31,10 +31,14 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.showSessionDialog +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.min class ConversationAdapter( context: Context, cursor: Cursor, + originalLastSeen: Long, + private val isReversed: Boolean, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, @@ -52,6 +56,8 @@ class ConversationAdapter( private val updateQueue = Channel(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val contactCache = SparseArray(100) private val contactLoadedCache = SparseBooleanArray(100) + private val lastSeen = AtomicLong(originalLastSeen) + init { lifecycleCoroutineScope.launch(IO) { while (isActive) { @@ -128,6 +134,7 @@ class ConversationAdapter( searchQuery, contact, senderId, + lastSeen.get(), visibleMessageViewDelegate, onAttachmentNeedsDownload ) @@ -183,14 +190,18 @@ class ConversationAdapter( private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually before the current one is actually after the current // one for the cursor because the layout is reversed - if (!cursor.moveToPosition(position + 1)) { return null } + if (isReversed && !cursor.moveToPosition(position + 1)) { return null } + if (!isReversed && !cursor.moveToPosition(position - 1)) { return null } + return messageDB.readerFor(cursor).current } private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually after the current one is actually before the current // one for the cursor because the layout is reversed - if (!cursor.moveToPosition(position - 1)) { return null } + if (isReversed && !cursor.moveToPosition(position - 1)) { return null } + if (!isReversed && !cursor.moveToPosition(position + 1)) { return null } + return messageDB.readerFor(cursor).current } @@ -217,11 +228,30 @@ class ConversationAdapter( fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? { val cursor = this.cursor - if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null + if (cursor == null || !isActiveCursor) return null + if (lastSeenTimestamp == 0L) { + if (isReversed && cursor.moveToLast()) { return cursor.position } + if (!isReversed && cursor.moveToFirst()) { return cursor.position } + } + + // Loop from the newest message to the oldest until we find one older (or equal to) + // the lastSeenTimestamp, then return that message index for (i in 0 until itemCount) { - cursor.moveToPosition(i) - val message = messageDB.readerFor(cursor).current - if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i } + if (isReversed) { + cursor.moveToPosition(i) + val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor) + if (outgoing || dateSent <= lastSeenTimestamp) { + return i + } + } + else { + val index = ((itemCount - 1) - i) + cursor.moveToPosition(index) + val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor) + if (outgoing || dateSent <= lastSeenTimestamp) { + return min(itemCount - 1, (index + 1)) + } + } } return null } @@ -231,8 +261,8 @@ class ConversationAdapter( if (timestamp <= 0L || cursor == null || !isActiveCursor) return null for (i in 0 until itemCount) { cursor.moveToPosition(i) - val message = messageDB.readerFor(cursor).current - if (message.dateSent == timestamp) { return i } + val (_, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor) + if (dateSent == timestamp) { return i } } return null } @@ -241,4 +271,11 @@ class ConversationAdapter( this.searchQuery = query notifyDataSetChanged() } + + fun getTimestampForItemAt(firstVisiblePosition: Int): Long? { + val cursor = this.cursor ?: return null + if (!cursor.moveToPosition(firstVisiblePosition)) return null + val message = messageDB.readerFor(cursor).current ?: return null + return message.timestamp + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b8b460b603..13736974b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,10 +1,10 @@ package org.thoughtcrime.securesms.conversation.v2 -import android.content.Context +import android.content.ContentResolver import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope +import app.cash.copper.flow.observeQuery import com.goterl.lazysodium.utils.KeyPair import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -21,15 +21,16 @@ import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import java.util.UUID class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, + private val contentResolver: ContentResolver, private val repository: ConversationRepository, private val storage: Storage ) : ViewModel() { @@ -37,7 +38,7 @@ class ConversationViewModel( val showSendAfterApprovalText: Boolean get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false - private val _uiState = MutableStateFlow(ConversationUiState()) + private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) val uiState: StateFlow = _uiState private var _recipient: RetrieveOnce = RetrieveOnce { @@ -61,6 +62,18 @@ class ConversationViewModel( ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString } + init { + viewModelScope.launch(Dispatchers.IO) { + contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)) + .collect { + val recipientExists = storage.getRecipientForThread(threadId) != null + if (!recipientExists && _uiState.value.conversationExists) { + _uiState.update { it.copy(conversationExists = false) } + } + } + } + } + fun saveDraft(text: String) { GlobalScope.launch(Dispatchers.IO) { repository.saveDraft(threadId, text) @@ -81,27 +94,17 @@ class ConversationViewModel( repository.inviteContacts(threadId, contacts) } - fun block(context: Context) { + fun block() { val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action") if (recipient.isContactRecipient) { repository.setBlocked(recipient, true) - - // TODO: Remove in UserConfig branch - GlobalScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } } } - fun unblock(context: Context) { + fun unblock() { val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") if (recipient.isContactRecipient) { repository.setBlocked(recipient, false) - - // TODO: Remove in UserConfig branch - GlobalScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } } } @@ -198,19 +201,20 @@ class ConversationViewModel( @dagger.assisted.AssistedFactory interface AssistedFactory { - fun create(threadId: Long, edKeyPair: KeyPair?): Factory + fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory } @Suppress("UNCHECKED_CAST") class Factory @AssistedInject constructor( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, + @Assisted private val contentResolver: ContentResolver, private val repository: ConversationRepository, private val storage: Storage ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return ConversationViewModel(threadId, edKeyPair, repository, storage) as T + return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T } } } @@ -219,7 +223,8 @@ data class UiMessage(val id: Long, val message: String) data class ConversationUiState( val uiMessages: List = emptyList(), - val isMessageRequestAccepted: Boolean? = null + val isMessageRequestAccepted: Boolean? = null, + val conversationExists: Boolean ) data class RetrieveOnce(val retrieval: () -> T?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index c90f5b2be7..7479c4a9ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -39,12 +39,7 @@ class BlockedDialog(private val recipient: Recipient, private val context: Conte } private fun unblock() { - DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false) + MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false) dismiss() - - // TODO: Remove in UserConfig branch - GlobalScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index e4cbd6ecee..a886e89192 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -36,7 +36,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D ThreadUtils.queue { try { openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) } - MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server) + MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) } catch (e: Exception) { Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 11add657f2..02ee4ae45f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -63,17 +63,18 @@ object ConversationMenuHelper { // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages - if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient)) { + if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) { if (thread.expireMessages > 0) { inflater.inflate(R.menu.menu_conversation_expiration_on, menu) val item = menu.findItem(R.id.menu_expiring_messages) - val actionView = item.actionView - val iconView = actionView.findViewById(R.id.menu_badge_icon) - val badgeView = actionView.findViewById(R.id.expiration_badge) - @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) - iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) - badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) - actionView.setOnClickListener { onOptionsItemSelected(item) } + item.actionView?.let { actionView -> + val iconView = actionView.findViewById(R.id.menu_badge_icon) + val badgeView = actionView.findViewById(R.id.expiration_badge) + @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) + iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) + badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) + actionView.setOnClickListener { onOptionsItemSelected(item) } + } } else { inflater.inflate(R.menu.menu_conversation_expiration_off, menu) } @@ -86,7 +87,7 @@ object ConversationMenuHelper { if (thread.isContactRecipient) { if (thread.isBlocked) { inflater.inflate(R.menu.menu_conversation_unblock, menu) - } else { + } else if (!thread.isLocalNumber) { inflater.inflate(R.menu.menu_conversation_block, menu) } } @@ -309,7 +310,7 @@ object ConversationMenuHelper { val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) - if (isClosedGroup) MessageSender.leave(groupPublicKey, true) + if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false) else onLeaveFailed() } catch (e: Exception) { onLeaveFailed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 75a3c58752..e7214cfa7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Color import android.graphics.Rect -import android.graphics.drawable.Drawable import android.text.Spannable import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan @@ -15,9 +14,7 @@ import android.view.View import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.BlendModeColorFilterCompat -import androidx.core.graphics.BlendModeCompat +import androidx.core.graphics.ColorUtils import androidx.core.text.getSpans import androidx.core.text.toSpannable import androidx.core.view.children @@ -28,6 +25,7 @@ import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 @@ -39,9 +37,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor -import java.util.* +import java.util.Locale import kotlin.math.roundToInt class VisibleMessageContentView : ConstraintLayout { @@ -69,12 +68,10 @@ class VisibleMessageContentView : ConstraintLayout { onAttachmentNeedsDownload: (Long, Long) -> Unit ) { // Background - val background = getBackground(message.isOutgoing) val color = if (message.isOutgoing) context.getAccentColor() else context.getColorFromAttr(R.attr.message_received_background_color) - val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) - background.colorFilter = filter - binding.contentParent.background = background + binding.contentParent.mainColor = color + binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) val onlyBodyMessage = message is SmsMessageRecord val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null @@ -131,7 +128,6 @@ class VisibleMessageContentView : ConstraintLayout { delegate?.scrollToMessageIfPossible(quote.id) } } - val hasMedia = message.slideDeck.asAttachments().isNotEmpty() } if (message is MmsMessageRecord) { @@ -244,11 +240,6 @@ class VisibleMessageContentView : ConstraintLayout { private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = listOf(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } - private fun getBackground(isOutgoing: Boolean): Drawable { - val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone - return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!! - } - fun recycle() { arrayOf( binding.deletedMessageView.root, @@ -266,6 +257,15 @@ class VisibleMessageContentView : ConstraintLayout { fun playVoiceMessage() { binding.voiceMessageView.root.togglePlayback() } + + fun playHighlight() { + // Show the highlight colour immediately then slowly fade out + val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme) + val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0) + binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1 + binding.contentParent.sessionShadowColor = targetColor + GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600) + } // endregion // region Convenience diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 319140731a..00c5c08a4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -111,6 +111,8 @@ class VisibleMessageView : LinearLayout { private fun initialize() { isHapticFeedbackEnabled = true setWillNotDraw(false) + binding.root.disableClipping() + binding.mainContainer.disableClipping() binding.messageInnerContainer.disableClipping() binding.messageContentView.root.disableClipping() } @@ -125,6 +127,7 @@ class VisibleMessageView : LinearLayout { searchQuery: String?, contact: Contact?, senderSessionID: String, + lastSeen: Long, delegate: VisibleMessageViewDelegate?, onAttachmentNeedsDownload: (Long, Long) -> Unit ) { @@ -164,6 +167,7 @@ class VisibleMessageView : LinearLayout { if (thread.isOpenGroupRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { + // TODO: support v2 soon val intent = Intent(context, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID)) @@ -177,7 +181,7 @@ class VisibleMessageView : LinearLayout { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return var standardPublicKey = "" var blindedPublicKey: String? = null - if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) { + if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) { blindedPublicKey = senderSessionID } else { standardPublicKey = senderSessionID @@ -191,6 +195,8 @@ class VisibleMessageView : LinearLayout { val contactContext = if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID + // Unread marker + binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing // Date break val showDateBreak = isStartOfMessageCluster || snIsSelected binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null @@ -409,6 +415,10 @@ class VisibleMessageView : LinearLayout { binding.profilePictureView.root.recycle() binding.messageContentView.root.recycle() } + + fun playHighlight() { + binding.messageContentView.root.playHighlight() + } // endregion // region Interaction diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index e1bf92c5f2..2b829af152 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -92,7 +92,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { if (progress == 1.0) { togglePlayback() handleProgressChanged(0.0) - delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter - 1) + delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter + 1) } else { handleProgressChanged(progress) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index dd90b699e3..088685241c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -25,6 +25,7 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; +import android.os.Build; import android.provider.OpenableColumns; import android.text.TextUtils; import android.util.Pair; @@ -244,12 +245,17 @@ public class AttachmentManager { } public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { - Permissions.with(activity) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) - .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) - .execute(); + Permissions.PermissionsBuilder builder = Permissions.with(activity); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO) + .request(Manifest.permission.READ_MEDIA_IMAGES); + } else { + builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE); + } + builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) + .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) + .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) + .execute(); } public static void selectAudio(Activity activity, int requestCode) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt new file mode 100644 index 0000000000..19a511bfd6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.core.content.contentValuesOf +import androidx.core.database.getBlobOrNull +import androidx.core.database.getLongOrNull +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper + +class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { + + companion object { + private const val VARIANT = "variant" + private const val PUBKEY = "publicKey" + private const val DATA = "data" + private const val TIMESTAMP = "timestamp" // Milliseconds + + private const val TABLE_NAME = "configs_table" + + const val CREATE_CONFIG_TABLE_COMMAND = + "CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));" + + private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?" + } + + fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) { + val db = writableDatabase + val contentValues = contentValuesOf( + VARIANT to variant, + PUBKEY to publicKey, + DATA to data, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey)) + } + + fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? { + val db = readableDatabase + val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) + return query?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val bytes = cursor.getBlobOrNull(cursor.getColumnIndex(DATA)) ?: return@use null + bytes + } + } + + fun retrieveConfigLastUpdateTimestamp(variant: String, publicKey: String): Long { + val db = readableDatabase + val cursor = db.query(TABLE_NAME, arrayOf(TIMESTAMP), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) + if (cursor == null) return 0 + if (!cursor.moveToFirst()) return 0 + return (cursor.getLongOrNull(cursor.getColumnIndex(TIMESTAMP)) ?: 0) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 79adead57e..66d01114ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -36,9 +36,9 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt @SuppressWarnings("unused") private static final String TAG = GroupDatabase.class.getSimpleName(); - static final String TABLE_NAME = "groups"; + public static final String TABLE_NAME = "groups"; private static final String ID = "_id"; - static final String GROUP_ID = "group_id"; + public static final String GROUP_ID = "group_id"; private static final String TITLE = "title"; private static final String MEMBERS = "members"; private static final String ZOMBIE_MEMBERS = "zombie_members"; @@ -133,12 +133,12 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt return new Reader(cursor); } - public List getAllGroups() { + public List getAllGroups(boolean includeInactive) { Reader reader = getGroups(); GroupRecord record; List groups = new LinkedList<>(); while ((record = reader.getNext()) != null) { - if (record.isActive()) { groups.add(record); } + if (record.isActive() || includeInactive) { groups.add(record); } } reader.close(); return groups; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index b0f6a676c7..53f4ea3196 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -458,9 +458,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) } - fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { + fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { val database = databaseHelper.writableDatabase - val timestamp = Date().time.toString() val index = "$groupPublicKey-$timestamp" val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded() val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt index 300217faba..1cbbf34c9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt @@ -4,11 +4,8 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.JsonUtil import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import org.thoughtcrime.securesms.dependencies.DatabaseComponent class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -24,12 +21,6 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);" } - fun getThreadID(hexEncodedPublicKey: String): Long { - val address = Address.fromSerialized(hexEncodedPublicKey) - val recipient = Recipient.from(context, address, false) - return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) - } - fun getAllOpenGroups(): Map { val database = databaseHelper.readableDatabase var cursor: Cursor? = null @@ -61,6 +52,13 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } } + fun getThreadId(openGroup: OpenGroup): Long? { + val database = databaseHelper.readableDatabase + return database.get(publicChatTable, "$publicChat = ?", arrayOf(JsonUtil.toJson(openGroup.toJson()))) { cursor -> + cursor.getLong(threadID) + } + } + fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) { if (threadID < 0) { return diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index e8f65dae06..111b6d5365 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -20,13 +20,11 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import com.annimon.stream.Stream -import com.google.android.mms.pdu_alt.NotificationInd import com.google.android.mms.pdu_alt.PduHeaders import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import org.session.libsession.messaging.messages.signal.IncomingMediaMessage -import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage @@ -41,16 +39,13 @@ import org.session.libsession.utilities.Address.Companion.UNKNOWN import org.session.libsession.utilities.Address.Companion.fromExternal import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Contact -import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.IdentityKeyMismatch import org.session.libsession.utilities.IdentityKeyMismatchList import org.session.libsession.utilities.NetworkFailure import org.session.libsession.utilities.NetworkFailureList import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.Util.toIsoBytes -import org.session.libsession.utilities.Util.toIsoString import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.recipients.RecipientFormattingException import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue @@ -162,7 +157,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) get(context).groupReceiptDatabase() .update(ourAddress, id, status, timestamp) - get(context).threadDatabase().update(threadId, false) + get(context).threadDatabase().update(threadId, false, true) notifyConversationListeners(threadId) } } @@ -205,25 +200,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } - @Throws(RecipientFormattingException::class, MmsException::class) - private fun getThreadIdFor(retrieved: IncomingMediaMessage): Long { - return if (retrieved.groupId != null) { - val groupRecipients = Recipient.from( - context, - retrieved.groupId, - true - ) - get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipients) - } else { - val sender = Recipient.from( - context, - retrieved.from, - true - ) - get(context).threadDatabase().getOrCreateThreadIdFor(sender) - } - } - private fun rawQuery(where: String, arguments: Array?): Cursor { val database = databaseHelper.readableDatabase return database.rawQuery( @@ -259,7 +235,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa " WHERE " + ID + " = ?", arrayOf(id.toString() + "") ) if (threadId.isPresent) { - get(context).threadDatabase().update(threadId.get(), false) + get(context).threadDatabase().update(threadId.get(), false, true) } } @@ -316,10 +292,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val attachmentDatabase = get(context).attachmentDatabase() queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) val threadId = getThreadIdForMessage(messageId) - if (!read) { - val mentionChange = if (hasMention) { 1 } else { 0 } - get(context).threadDatabase().decrementUnread(threadId, 1, mentionChange) - } + markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) } @@ -343,6 +316,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) } + fun setMessagesRead(threadId: Long, beforeTime: Long): List { + return setMessagesRead( + THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", + arrayOf(threadId.toString(), beforeTime.toString()) + ) + } + fun setMessagesRead(threadId: Long): List { return setMessagesRead( THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", @@ -567,18 +547,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentLocation: String, threadId: Long, mailbox: Long, serverTimestamp: Long, - runIncrement: Boolean, runThreadUpdate: Boolean ): Optional { - var threadId = threadId - if (threadId == -1L || retrieved.isGroupMessage) { - try { - threadId = getThreadIdFor(retrieved) - } catch (e: RecipientFormattingException) { - Log.w("MmsDatabase", e) - if (threadId == -1L) throw MmsException(e) - } - } + if (threadId < 0 ) throw MmsException("No thread ID supplied!") val contentValues = ContentValues() contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(ADDRESS, retrieved.from.serialize()) @@ -632,12 +603,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa null, ) if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { - if (runIncrement) { - val mentionAmount = if (retrieved.hasMention()) { 1 } else { 0 } - get(context).threadDatabase().incrementUnread(threadId, 1, mentionAmount) - } if (runThreadUpdate) { - get(context).threadDatabase().update(threadId, true) + get(context).threadDatabase().update(threadId, true, true) } } notifyConversationListeners(threadId) @@ -651,27 +618,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa serverTimestamp: Long, runThreadUpdate: Boolean ): Optional { - var threadId = threadId - if (threadId == -1L) { - if (retrieved.isGroup) { - val decodedGroupId: String = if (retrieved is OutgoingExpirationUpdateMessage) { - retrieved.groupId - } else { - (retrieved as OutgoingGroupMediaMessage).groupId - } - val groupId: String - groupId = try { - doubleEncodeGroupID(decodedGroupId) - } catch (e: IOException) { - Log.e(TAG, "Couldn't encrypt group ID") - throw MmsException(e) - } - val group = Recipient.from(context, fromSerialized(groupId), false) - threadId = get(context).threadDatabase().getOrCreateThreadIdFor(group) - } else { - threadId = get(context).threadDatabase().getOrCreateThreadIdFor(retrieved.recipient) - } - } + if (threadId < 0 ) throw MmsException("No thread ID supplied!") val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) if (messageId == -1L) { return Optional.absent() @@ -686,7 +633,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa retrieved: IncomingMediaMessage, threadId: Long, serverTimestamp: Long = 0, - runIncrement: Boolean, runThreadUpdate: Boolean ): Optional { var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT @@ -705,7 +651,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa if (retrieved.isMessageRequestResponse) { type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT } - return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runIncrement, runThreadUpdate) + return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runThreadUpdate) } @JvmOverloads @@ -794,10 +740,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } with (get(context).threadDatabase()) { - setLastSeen(threadId) + val lastSeen = getLastSeenAndHasSent(threadId).first() + if (lastSeen < message.sentTimeMillis) { + setLastSeen(threadId, message.sentTimeMillis) + } setHasSent(threadId, true) if (runThreadUpdate) { - update(threadId, true) + update(threadId, true, true) } } return messageId @@ -932,7 +881,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa groupReceiptDatabase.deleteRowsForMessage(messageId) val database = databaseHelper.writableDatabase database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) - val threadDeleted = get(context).threadDatabase().update(threadId, false) + val threadDeleted = get(context).threadDatabase().update(threadId, false, true) notifyConversationListeners(threadId) notifyStickerListeners() notifyStickerPackListeners() @@ -949,7 +898,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val database = databaseHelper.writableDatabase database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(","))) - val threadDeleted = get(context).threadDatabase().update(threadId, false) + val threadDeleted = get(context).threadDatabase().update(threadId, false, true) notifyConversationListeners(threadId) notifyStickerListeners() notifyStickerPackListeners() @@ -1147,7 +1096,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } val threadDb = get(context).threadDatabase() for (threadId in threadIds) { - val threadDeleted = threadDb.update(threadId, false) + val threadDeleted = threadDb.update(threadId, false, true) notifyConversationListeners(threadId) } notifyStickerListeners() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index c7f9d61324..0db4dd00e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms.database; +import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX; + import android.content.Context; import android.database.Cursor; @@ -25,6 +27,7 @@ import androidx.annotation.Nullable; import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; +import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Util; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; @@ -36,6 +39,8 @@ import java.io.Closeable; import java.util.HashSet; import java.util.Set; +import kotlin.Pair; + public class MmsSmsDatabase extends Database { @SuppressWarnings("unused") @@ -259,8 +264,8 @@ public class MmsSmsDatabase extends Database { return -1; } - public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address, boolean reverse) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { @@ -512,6 +517,23 @@ public class MmsSmsDatabase extends Database { return new Reader(cursor); } + @NotNull + public Pair timestampAndDirectionForCurrent(@NotNull Cursor cursor) { + int sentColumn = cursor.getColumnIndex(MmsSmsColumns.NORMALIZED_DATE_SENT); + String msgType = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT)); + long sentTime = cursor.getLong(sentColumn); + long type = 0; + if (MmsSmsDatabase.MMS_TRANSPORT.equals(msgType)) { + int typeIndex = cursor.getColumnIndex(MESSAGE_BOX); + type = cursor.getLong(typeIndex); + } else if (MmsSmsDatabase.SMS_TRANSPORT.equals(msgType)) { + int typeIndex = cursor.getColumnIndex(SmsDatabase.TYPE); + type = cursor.getLong(typeIndex); + } + + return new Pair(MmsSmsColumns.Types.isOutgoingMessageType(type), sentTime); + } + public class Reader implements Closeable { private final Cursor cursor; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index e3570fd283..b7b8364184 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -62,13 +62,14 @@ public class RecipientDatabase extends Database { private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; private static final String FORCE_SMS_SELECTION = "force_sms_selection"; private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none + private static final String WRAPPER_HASH = "wrapper_hash"; private static final String[] RECIPIENT_PROJECTION = new String[] { BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, UNIDENTIFIED_ACCESS_MODE, - FORCE_SMS_SELECTION, NOTIFY_TYPE, + FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH }; static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -136,6 +137,11 @@ public class RecipientDatabase extends Database { "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; } + public static String getAddWrapperHash() { + return "ALTER TABLE "+TABLE_NAME+" "+ + "ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;"; + } + public static final int NOTIFY_TYPE_ALL = 0; public static final int NOTIFY_TYPE_MENTIONS = 1; public static final int NOTIFY_TYPE_NONE = 2; @@ -154,18 +160,14 @@ public class RecipientDatabase extends Database { public Optional getRecipientSettings(@NonNull Address address) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - try { - cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null); + try (Cursor cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) { if (cursor != null && cursor.moveToNext()) { return getRecipientSettings(cursor); } return Optional.absent(); - } finally { - if (cursor != null) cursor.close(); } } @@ -194,6 +196,7 @@ public class RecipientDatabase extends Database { String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; + String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH)); MaterialColor color; byte[] profileKey = null; @@ -225,7 +228,7 @@ public class RecipientDatabase extends Database { systemPhoneLabel, systemContactUri, signalProfileName, signalProfileAvatar, profileSharing, notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), - forceSmsSelection)); + forceSmsSelection, wrapperHash)); } public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { @@ -252,6 +255,24 @@ public class RecipientDatabase extends Database { notifyRecipientListeners(); } + public boolean getApproved(@NonNull Address address) { + SQLiteDatabase db = getReadableDatabase(); + try (Cursor cursor = db.query(TABLE_NAME, new String[]{APPROVED}, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; + } + } + return false; + } + + public void setRecipientHash(@NonNull Recipient recipient, String recipientHash) { + ContentValues values = new ContentValues(); + values.put(WRAPPER_HASH, recipientHash); + updateOrInsert(recipient.getAddress(), values); + recipient.resolve().setWrapperHash(recipientHash); + notifyRecipientListeners(); + } + public void setApproved(@NonNull Recipient recipient, boolean approved) { ContentValues values = new ContentValues(); values.put(APPROVED, approved ? 1 : 0); @@ -268,14 +289,6 @@ public class RecipientDatabase extends Database { notifyRecipientListeners(); } - public void setBlocked(@NonNull Recipient recipient, boolean blocked) { - ContentValues values = new ContentValues(); - values.put(BLOCK, blocked ? 1 : 0); - updateOrInsert(recipient.getAddress(), values); - recipient.resolve().setBlocked(blocked); - notifyRecipientListeners(); - } - public void setBlocked(@NonNull Iterable recipients, boolean blocked) { SQLiteDatabase db = getWritableDatabase(); db.beginTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 40eee97428..49a6339368 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context -import androidx.core.database.getStringOrNull import android.database.Cursor +import androidx.core.database.getStringOrNull import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.utilities.SessionId import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -43,6 +45,9 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da val database = databaseHelper.readableDatabase return database.getAll(sessionContactTable, null, null) { cursor -> contactFromCursor(cursor) + }.filter { contact -> + val sessionId = SessionId(contact.sessionID) + sessionId.prefix == IdPrefix.STANDARD }.toSet() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index b081fb007e..6221446aae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -93,6 +93,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa fun cancelPendingMessageSendJobs(threadID: Long) { val database = databaseHelper.writableDatabase val attachmentUploadJobKeys = mutableListOf() + database.beginTransaction() database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor -> val job = jobFromCursor(cursor) as AttachmentUploadJob? if (job != null && job.threadID == threadID.toString()) { attachmentUploadJobKeys.add(job.id!!) } @@ -103,15 +104,19 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa if (job != null && job.message.threadID == threadID) { messageSendJobKeys.add(job.id!!) } } if (attachmentUploadJobKeys.isNotEmpty()) { - val attachmentUploadJobKeysAsString = attachmentUploadJobKeys.joinToString(", ") - database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", - arrayOf( AttachmentUploadJob.KEY, attachmentUploadJobKeysAsString )) + attachmentUploadJobKeys.forEach { + database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?", + arrayOf( AttachmentUploadJob.KEY, it )) + } } if (messageSendJobKeys.isNotEmpty()) { - val messageSendJobKeysAsString = messageSendJobKeys.joinToString(", ") - database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", - arrayOf( MessageSendJob.KEY, messageSendJobKeysAsString )) + messageSendJobKeys.forEach { + database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?", + arrayOf( MessageSendJob.KEY, it )) + } } + database.setTransactionSuccessful() + database.endTransaction() } fun isJobCanceled(job: Job): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 42a00ccbb2..4ef576f404 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -148,7 +148,7 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); } @@ -234,10 +234,6 @@ public class SmsDatabase extends MessagingDatabase { contentValues.put(BODY, ""); contentValues.put(HAS_MENTION, 0); database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - long threadId = getThreadIdForMessage(messageId); - if (!read) { - DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1, (hasMention ? 1 : 0)); - } updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); } @@ -256,7 +252,7 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); } @@ -319,7 +315,7 @@ public class SmsDatabase extends MessagingDatabase { ID + " = ?", new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); foundMessage = true; } @@ -337,6 +333,9 @@ public class SmsDatabase extends MessagingDatabase { } } + public List setMessagesRead(long threadId, long beforeTime) { + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""}); + } public List setMessagesRead(long threadId) { return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)}); } @@ -400,14 +399,14 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(messageId); - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); notifyConversationListeners(threadId); notifyConversationListListeners(); return new Pair<>(messageId, threadId); } - protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { + protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; } else if (message.isGroup()) { @@ -486,12 +485,8 @@ public class SmsDatabase extends MessagingDatabase { SQLiteDatabase db = databaseHelper.getWritableDatabase(); long messageId = db.insert(TABLE_NAME, null, values); - if (unread && runIncrement) { - DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1, (message.hasMention() ? 1 : 0)); - } - if (runThreadUpdate) { - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); } if (message.getSubscriptionId() != -1) { @@ -504,16 +499,16 @@ public class SmsDatabase extends MessagingDatabase { } } - public Optional insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate); + public Optional insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate); } public Optional insertCallMessage(IncomingTextMessage message) { - return insertMessageInbox(message, 0, 0, true, true); + return insertMessageInbox(message, 0, 0, true); } - public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate); + public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate); } public Optional insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { @@ -567,9 +562,12 @@ public class SmsDatabase extends MessagingDatabase { } if (runThreadUpdate) { - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); + } + long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first(); + if (lastSeen < message.getSentTimestampMillis()) { + DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId, message.getSentTimestampMillis()); } - DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId); DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true); @@ -616,7 +614,7 @@ public class SmsDatabase extends MessagingDatabase { SQLiteDatabase db = databaseHelper.getWritableDatabase(); long threadId = getThreadIdForMessage(messageId); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); + boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); return threadDeleted; } @@ -640,7 +638,7 @@ public class SmsDatabase extends MessagingDatabase { ID + " IN (" + StringUtils.join(argsArray, ',') + ")", argValues ); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); + boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); return threadDeleted; } 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 365c12b839..c77ad1c638 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,16 +2,43 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.jobs.* +import org.session.libsession.messaging.jobs.AttachmentUploadJob +import org.session.libsession.messaging.jobs.BackgroundGroupAddJob +import org.session.libsession.messaging.jobs.ConfigurationSyncJob +import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob +import org.session.libsession.messaging.jobs.Job +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageReceiveJob +import org.session.libsession.messaging.jobs.MessageSendJob +import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob +import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse -import org.session.libsession.messaging.messages.signal.* +import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage +import org.session.libsession.messaging.messages.signal.IncomingGroupMessage +import org.session.libsession.messaging.messages.signal.IncomingMediaMessage +import org.session.libsession.messaging.messages.signal.IncomingTextMessage +import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Reaction @@ -23,12 +50,15 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI +import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.utilities.* +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil @@ -36,24 +66,104 @@ import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.crypto.ecc.DjbECPrivateKey +import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.KeyHelper +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.groups.ClosedGroupManager +import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.security.MessageDigest +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact + +open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol, + ThreadDatabase.ConversationThreadUpdateListener { + + override fun threadCreated(address: Address, threadId: Long) { + val localUserAddress = getUserPublicKey() ?: return + if (!getRecipientApproved(address) && localUserAddress != address.serialize()) return // don't store unapproved / message requests + + val volatile = configFactory.convoVolatile ?: return + if (address.isGroup) { + val groups = configFactory.userGroups ?: return + if (address.isClosedGroup) { + val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) + val closedGroup = getGroup(address.toGroupString()) + if (closedGroup != null && closedGroup.isActive) { + val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId) + groups.set(legacyGroup) + val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy( + lastRead = SnodeAPI.nowWithOffset, + ) + volatile.set(newVolatileParams) + } + } else if (address.isOpenGroup) { + // these should be added on the group join / group info fetch + Log.w("Loki", "Thread created called for open group address, not adding any extra information") + } + } else if (address.isContact) { + // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config + if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return + // don't update our own address into the contacts DB + if (getUserPublicKey() != address.serialize()) { + val contacts = configFactory.contacts ?: return + contacts.upsertContact(address.serialize()) { + priority = ConfigBase.PRIORITY_VISIBLE + } + } else { + val userProfile = configFactory.user ?: return + userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE) + DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true) + } + val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize()) + volatile.set(newVolatileParams) + } + } + + override fun threadDeleted(address: Address, threadId: Long) { + val volatile = configFactory.convoVolatile ?: return + if (address.isGroup) { + val groups = configFactory.userGroups ?: return + if (address.isClosedGroup) { + val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) + volatile.eraseLegacyClosedGroup(sessionId) + groups.eraseLegacyGroup(sessionId) + } else if (address.isOpenGroup) { + // these should be removed in the group leave / handling new configs + Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere") + } + } else { + // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config + if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return + volatile.eraseOneToOne(address.serialize()) + if (getUserPublicKey() != address.serialize()) { + val contacts = configFactory.contacts ?: return + contacts.upsertContact(address.serialize()) { + priority = PRIORITY_HIDDEN + } + } else { + val userProfile = configFactory.user ?: return + userProfile.setNtsPriority(PRIORITY_HIDDEN) + } + } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } -class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), StorageProtocol { - override fun getUserPublicKey(): String? { return TextSecurePreferences.getLocalNumber(context) } @@ -74,6 +184,25 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, database.setProfileAvatar(recipient, profileAvatar) } + override fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) { + val db = DatabaseComponent.get(context).recipientDatabase() + db.setProfileAvatar(recipient, newProfilePicture) + db.setProfileKey(recipient, newProfileKey) + } + + override fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) { + val ourRecipient = fromSerialized(getUserPublicKey()!!).let { + Recipient.from(context, it, false) + } + ourRecipient.resolve().profileKey = newProfileKey + TextSecurePreferences.setProfileKey(context, newProfileKey?.let { Base64.encodeBytes(it) }) + TextSecurePreferences.setProfilePictureURL(context, newProfilePicture) + + if (newProfileKey != null) { + JobQueue.shared.add(RetrieveProfileAvatarJob(newProfilePicture, ourRecipient.address)) + } + } + override fun getOrGenerateRegistrationID(): Int { var registrationID = TextSecurePreferences.getLocalRegistrationId(context) if (registrationID == 0) { @@ -94,19 +223,56 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return database.getAttachmentsForMessage(messageID) } - override fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) { + override fun getLastSeen(threadId: Long): Long { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.setRead(threadId, updateLastSeen) + return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L } - override fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int) { + override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.incrementUnread(threadId, amount, unreadMentionAmount) + getRecipientForThread(threadId)?.let { recipient -> + val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() + // don't set the last read in the volatile if we didn't set it in the DB + if (!threadDb.markAllAsRead(threadId, recipient.isGroupRecipient, lastSeenTime, force) && !force) return + + // don't process configs for inbox recipients + if (recipient.isOpenGroupInboxRecipient) return + + configFactory.convoVolatile?.let { config -> + val convo = when { + // recipient closed group + recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + // recipient is open group + recipient.isOpenGroupRecipient -> { + val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return + BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) -> + config.getOrConstructCommunity(base, room, pubKey) + } ?: return + } + // otherwise recipient is one to one + recipient.isContactRecipient -> { + // don't process non-standard session IDs though + val sessionId = SessionId(recipient.address.serialize()) + if (sessionId.prefix != IdPrefix.STANDARD) return + + config.getOrConstructOneToOne(recipient.address.serialize()) + } + else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}") + } + convo.lastRead = lastSeenTime + if (convo.unread) { + convo.unread = lastSeenTime <= currentLastRead + notifyConversationListListeners() + } + config.set(convo) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + } } override fun updateThread(threadId: Long, unarchive: Boolean) { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.update(threadId, unarchive) + threadDb.update(threadId, unarchive, false) } override fun persist(message: VisibleMessage, @@ -115,7 +281,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, groupPublicKey: String?, openGroupID: String?, attachments: List, - runIncrement: Boolean, runThreadUpdate: Boolean): Long? { var messageID: Long? = null val senderAddress = fromSerialized(message.sender!!) @@ -142,13 +307,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } val targetRecipient = Recipient.from(context, targetAddress, false) if (!targetRecipient.isGroupRecipient) { - val recipientDb = DatabaseComponent.get(context).recipientDatabase() if (isUserSender || isUserBlindedSender) { - recipientDb.setApproved(targetRecipient, true) + setRecipientApproved(targetRecipient, true) } else { - recipientDb.setApprovedMe(targetRecipient, true) + setRecipientApprovedMe(targetRecipient, true) } } + if (message.threadID == null && !targetRecipient.isOpenGroupRecipient) { + // open group recipients should explicitly create threads + message.threadID = getOrCreateThreadIdFor(targetAddress) + } if (message.isMediaMessage() || attachments.isNotEmpty()) { val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) @@ -162,7 +330,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, it.toSignalPointer() } val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews) - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID ?: -1, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) } if (insertResult.isPresent) { messageID = insertResult.get().messageId @@ -179,7 +347,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp) else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) - smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) + smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) } insertResult.orNull()?.let { result -> messageID = result.messageId @@ -225,6 +393,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId) } + override fun getConfigSyncJob(destination: Destination): Job? { + return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(ConfigurationSyncJob.KEY).values.firstOrNull { + (it as? ConfigurationSyncJob)?.destination == destination + } + } + override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) { val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return JobQueue.shared.resumePendingSendMessage(job) @@ -234,11 +408,201 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job) } + override fun cancelPendingMessageSendJobs(threadID: Long) { + val jobDb = DatabaseComponent.get(context).sessionJobDatabase() + jobDb.cancelPendingMessageSendJobs(threadID) + } + override fun getAuthToken(room: String, server: String): String? { val id = "$server.$room" return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) } + override fun notifyConfigUpdates(forConfigObject: ConfigBase) { + notifyUpdates(forConfigObject) + } + + override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean { + return configFactory.conversationInConfig(publicKey, groupPublicKey, openGroupId, visibleOnly) + } + + override fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { + return configFactory.canPerformChange(variant, publicKey, changeTimestampMs) + } + + fun notifyUpdates(forConfigObject: ConfigBase) { + when (forConfigObject) { + is UserProfile -> updateUser(forConfigObject) + is Contacts -> updateContacts(forConfigObject) + is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject) + is UserGroupsConfig -> updateUserGroups(forConfigObject) + } + } + + private fun updateUser(userProfile: UserProfile) { + val userPublicKey = getUserPublicKey() ?: return + // would love to get rid of recipient and context from this + val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) + // update name + val name = userProfile.getName() ?: return + val userPic = userProfile.getPic() + val profileManager = SSKEnvironment.shared.profileManager + if (name.isNotEmpty()) { + TextSecurePreferences.setProfileName(context, name) + profileManager.setName(context, recipient, name) + } + + // update pfp + if (userPic == UserPic.DEFAULT) { + clearUserPic() + } else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty() + && TextSecurePreferences.getProfilePictureURL(context) != userPic.url) { + setUserProfilePicture(userPic.url, userPic.key) + } + if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) { + // delete nts thread if needed + val ourThread = getThreadId(recipient) ?: return + deleteConversation(ourThread) + } else { + // create note to self thread if needed (?) + val ourThread = getOrCreateThreadIdFor(recipient.address) + DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true) + setPinned(ourThread, userProfile.getNtsPriority() > 0) + } + + } + + private fun updateContacts(contacts: Contacts) { + val extracted = contacts.all().toList() + addLibSessionContacts(extracted) + } + + override fun clearUserPic() { + val userPublicKey = getUserPublicKey() ?: return + val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() + // would love to get rid of recipient and context from this + val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) + // clear picture if userPic is null + TextSecurePreferences.setProfileKey(context, null) + ProfileKeyUtil.setEncodedProfileKey(context, null) + recipientDatabase.setProfileAvatar(recipient, null) + TextSecurePreferences.setProfileAvatarId(context, 0) + TextSecurePreferences.setProfilePictureURL(context, null) + + Recipient.removeCached(fromSerialized(userPublicKey)) + configFactory.user?.setPic(UserPic.DEFAULT) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + + private fun updateConvoVolatile(convos: ConversationVolatileConfig) { + val extracted = convos.all() + for (conversation in extracted) { + val threadId = when (conversation) { + is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false) + is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false) + is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) + } + if (threadId != null) { + if (conversation.lastRead > getLastSeen(threadId)) { + markConversationAsRead(threadId, conversation.lastRead, force = true) + } + updateThread(threadId, false) + } + } + } + + private fun updateUserGroups(userGroups: UserGroupsConfig) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + val localUserPublicKey = getUserPublicKey() ?: return Log.w( + "Loki", + "No user public key when trying to update user groups from config" + ) + val communities = userGroups.allCommunityInfo() + val lgc = userGroups.allLegacyGroupInfo() + val allOpenGroups = getAllOpenGroups() + val toDeleteCommunities = allOpenGroups.filter { + Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in communities.map { it.community.fullUrl() } + } + + val existingCommunities: Map = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys } + val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } } + val existingJoinUrls = existingCommunities.values.map { it.joinURL } + + val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup } + val lgcIds = lgc.map { it.sessionId } + val toDeleteClosedGroups = existingClosedGroups.filter { group -> + GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds + } + + // delete the ones which are not listed in the config + toDeleteCommunities.values.forEach { openGroup -> + OpenGroupManager.delete(openGroup.server, openGroup.room, context) + } + + toDeleteClosedGroups.forEach { deleteGroup -> + val threadId = getThreadId(deleteGroup.encodedId) + if (threadId != null) { + ClosedGroupManager.silentlyRemoveGroup(context,threadId,GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true) + } + } + + toAddCommunities.forEach { toAddCommunity -> + val joinUrl = toAddCommunity.community.fullUrl() + if (!hasBackgroundGroupAddJob(joinUrl)) { + JobQueue.shared.add(BackgroundGroupAddJob(joinUrl)) + } + } + + for (groupInfo in communities) { + val groupBaseCommunity = groupInfo.community + if (groupBaseCommunity.fullUrl() in existingJoinUrls) { + // add it + val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() } + threadDb.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED) + } + } + + for (group in lgc) { + val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId } + val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } + if (existingGroup != null) { + if (group.priority == PRIORITY_HIDDEN && existingThread != null) { + ClosedGroupManager.silentlyRemoveGroup(context,existingThread,GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true) + } else if (existingThread == null) { + Log.w("Loki-DBG", "Existing group had no thread to hide") + } else { + Log.d("Loki-DBG", "Setting existing group pinned status to ${group.priority}") + threadDb.setPinned(existingThread, group.priority == PRIORITY_PINNED) + } + } else { + val members = group.members.keys.map { Address.fromSerialized(it) } + val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) } + val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId) + val title = group.name + val formationTimestamp = (group.joinedAt * 1000L) + createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) + setProfileSharing(Address.fromSerialized(groupId), true) + // Add the group to the user's set of public keys to poll for + addClosedGroupPublicKey(group.sessionId) + // Store the encryption key pair + val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) + addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset) + // Set expiration timer + val expireTimer = group.disappearingTimer + setExpirationTimer(groupId, expireTimer.toInt()) + // Notify the PN server + PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, group.sessionId, localUserPublicKey) + // Notify the user + val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) + threadDb.setDate(threadID, formationTimestamp) + insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) + // Don't create config group here, it's from a config update + // Start polling + ClosedGroupPollerV2.shared.startPolling(group.sessionId) + } + } + } + override fun setAuthToken(room: String, server: String, newValue: String) { val id = "$server.$room" DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue) @@ -474,6 +838,59 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) } + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) { + val volatiles = configFactory.convoVolatile ?: return + val userGroups = configFactory.userGroups ?: return + val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey) + groupVolatileConfig.lastRead = formationTimestamp + volatiles.set(groupVolatileConfig) + val groupInfo = GroupInfo.LegacyGroupInfo( + sessionId = groupPublicKey, + name = name, + members = members, + priority = ConfigBase.PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = 0L, + joinedAt = (formationTimestamp / 1000L) + ) + // shouldn't exist, don't use getOrConstruct + copy + userGroups.set(groupInfo) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + + override fun updateGroupConfig(groupPublicKey: String) { + val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) + val groupAddress = fromSerialized(groupID) + // TODO: probably add a check in here for isActive? + // TODO: also check if local user is a member / maybe run delete otherwise? + val existingGroup = getGroup(groupID) + ?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config") + val userGroups = configFactory.userGroups ?: return + if (!existingGroup.isActive) { + userGroups.eraseLegacyGroup(groupPublicKey) + return + } + val name = existingGroup.title + val admins = existingGroup.admins.map { it.serialize() } + val members = existingGroup.members.map { it.serialize() } + val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members) + val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey) + ?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config") + val recipientSettings = getRecipientSettings(groupAddress) ?: return + val threadID = getThreadId(groupAddress) ?: return + val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy( + name = name, + members = membersMap, + encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = latestKeyPair.privateKey.serialize(), + priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + disappearingTimer = recipientSettings.expireMessages.toLong(), + joinedAt = (existingGroup.formationTimestamp / 1000L) + ) + userGroups.set(groupInfo) + } + override fun isGroupActive(groupPublicKey: String): Boolean { return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true } @@ -504,7 +921,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() - smsDB.insertMessageInbox(infoMessage, true, true) + smsDB.insertMessageInbox(infoMessage, true) } override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) { @@ -552,8 +969,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey) } - override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { - DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { + DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp) } override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { @@ -570,9 +987,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, .updateTimestampUpdated(groupID, updatedTimestamp) } - override fun setExpirationTimer(groupID: String, duration: Int) { - val recipient = Recipient.from(context, fromSerialized(groupID), false) - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); + override fun setExpirationTimer(address: String, duration: Int) { + val recipient = Recipient.from(context, fromSerialized(address), false) + DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration) + if (recipient.isContactRecipient && !recipient.isLocalNumber) { + configFactory.contacts?.upsertContact(address) { + this.expiryMode = if (duration != 0) { + ExpiryMode.AfterRead(duration.toLong()) + } else { // = 0 / delete + ExpiryMode.NONE + } + } + if (configFactory.contacts?.needsPush() == true) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + } } override fun setServerCapabilities(server: String, capabilities: List) { @@ -591,16 +1020,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, OpenGroupManager.updateOpenGroup(openGroup, context) } - override fun getAllGroups(): List { - return DatabaseComponent.get(context).groupDatabase().allGroups + override fun getAllGroups(includeInactive: Boolean): List { + return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive) } override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { return OpenGroupManager.addOpenGroup(urlAsString, context) } - override fun onOpenGroupAdded(server: String) { + override fun onOpenGroupAdded(server: String, room: String) { OpenGroupManager.restartPollerForServer(server.removeSuffix("/")) + val groups = configFactory.userGroups ?: return + val volatileConfig = configFactory.convoVolatile ?: return + val openGroup = getOpenGroup(room, server) ?: return + val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return + val pubKeyHex = Hex.toStringCondensed(pubKey) + val communityInfo = groups.getOrConstructCommunityInfo(infoServer, infoRoom, pubKeyHex) + groups.set(communityInfo) + val volatile = volatileConfig.getOrConstructCommunity(infoServer, infoRoom, pubKey) + if (volatile.lastRead != 0L) { + val threadId = getThreadId(openGroup) ?: return + markConversationAsRead(threadId, volatile.lastRead, force = true) + } + volatileConfig.set(volatile) } override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { @@ -618,17 +1060,19 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) } - override fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long { + override fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? { val database = DatabaseComponent.get(context).threadDatabase() return if (!openGroupID.isNullOrEmpty()) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) - database.getThreadIdIfExistsFor(recipient) + database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } else if (!groupPublicKey.isNullOrEmpty()) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) - database.getOrCreateThreadIdFor(recipient) + if (createThread) database.getOrCreateThreadIdFor(recipient) + else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } else { val recipient = Recipient.from(context, fromSerialized(publicKey), false) - database.getOrCreateThreadIdFor(recipient) + if (createThread) database.getOrCreateThreadIdFor(recipient) + else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } } @@ -637,6 +1081,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return getThreadId(address) } + override fun getThreadId(openGroup: OpenGroup): Long? { + return GroupManager.getOpenGroupThreadID("${openGroup.server.removeSuffix("/")}.${openGroup.room}", context) + } + override fun getThreadId(address: Address): Long? { val recipient = Recipient.from(context, address, false) return getThreadId(recipient) @@ -666,6 +1114,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun setContact(contact: Contact) { DatabaseComponent.get(context).sessionContactDatabase().setContact(contact) + val address = fromSerialized(contact.sessionID) + if (!getRecipientApproved(address)) return + val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact) + val recipient = Recipient.from(context, address, false) + setRecipientHash(recipient, recipientHash) } override fun getRecipientForThread(threadId: Long): Recipient? { @@ -677,6 +1130,51 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return if (recipientSettings.isPresent) { recipientSettings.get() } else null } + override fun addLibSessionContacts(contacts: List) { + val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() + val moreContacts = contacts.filter { contact -> + val id = SessionId(contact.id) + id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null } + } + val profileManager = SSKEnvironment.shared.profileManager + moreContacts.forEach { contact -> + val address = fromSerialized(contact.id) + val recipient = Recipient.from(context, address, false) + setBlocked(listOf(recipient), contact.blocked, fromConfigUpdate = true) + setRecipientApproved(recipient, contact.approved) + setRecipientApprovedMe(recipient, contact.approvedMe) + if (contact.name.isNotEmpty()) { + profileManager.setName(context, recipient, contact.name) + } else { + profileManager.setName(context, recipient, null) + } + if (contact.nickname.isNotEmpty()) { + profileManager.setNickname(context, recipient, contact.nickname) + } else { + profileManager.setNickname(context, recipient, null) + } + + if (contact.profilePicture != UserPic.DEFAULT) { + val (url, key) = contact.profilePicture + if (key.size != ProfileKeyUtil.PROFILE_KEY_BYTES) return@forEach + profileManager.setProfilePicture(context, recipient, url, key) + profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) + } else { + profileManager.setProfilePicture(context, recipient, null, null) + } + if (contact.priority == PRIORITY_HIDDEN) { + getThreadId(fromSerialized(contact.id))?.let { conversationThreadId -> + deleteConversation(conversationThreadId) + } + } else { + getThreadId(fromSerialized(contact.id))?.let { conversationThreadId -> + setPinned(conversationThreadId, contact.priority == PRIORITY_PINNED) + } + } + setRecipientHash(recipient, contact.hashCode().toString()) + } + } + override fun addContacts(contacts: List) { val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() val threadDatabase = DatabaseComponent.get(context).threadDatabase() @@ -700,19 +1198,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, recipientDatabase.setProfileSharing(recipient, true) recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED) // create Thread if needed - val threadId = threadDatabase.getOrCreateThreadIdFor(recipient) + val threadId = threadDatabase.getThreadIdIfExistsFor(recipient) if (contact.didApproveMe == true) { recipientDatabase.setApprovedMe(recipient, true) } - if (contact.isApproved == true) { - recipientDatabase.setApproved(recipient, true) + if (contact.isApproved == true && threadId != -1L) { + setRecipientApproved(recipient, true) threadDatabase.setHasSent(threadId, true) } val contactIsBlocked: Boolean? = contact.isBlocked if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) { - recipientDatabase.setBlocked(recipient, contactIsBlocked) - threadDatabase.deleteConversation(threadId) + setBlocked(listOf(recipient), contactIsBlocked, fromConfigUpdate = true) } } if (contacts.isNotEmpty()) { @@ -720,6 +1217,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } + override fun setRecipientHash(recipient: Recipient, recipientHash: String?) { + val recipientDb = DatabaseComponent.get(context).recipientDatabase() + recipientDb.setRecipientHash(recipient, recipientHash) + } + override fun getLastUpdated(threadID: Long): Long { val threadDB = DatabaseComponent.get(context).threadDatabase() return threadDB.getLastUpdated(threadID) @@ -740,12 +1242,78 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return mmsSmsDb.getConversationCount(threadID) } - override fun deleteConversation(threadId: Long) { + override fun setPinned(threadID: Long, isPinned: Boolean) { val threadDB = DatabaseComponent.get(context).threadDatabase() - threadDB.deleteConversation(threadId) + threadDB.setPinned(threadID, isPinned) + val threadRecipient = getRecipientForThread(threadID) ?: return + if (threadRecipient.isLocalNumber) { + val user = configFactory.user ?: return + user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE) + } else if (threadRecipient.isContactRecipient) { + val contacts = configFactory.contacts ?: return + contacts.upsertContact(threadRecipient.address.serialize()) { + priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + } + } else if (threadRecipient.isGroupRecipient) { + val groups = configFactory.userGroups ?: return + if (threadRecipient.isClosedGroupRecipient) { + val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize()) + val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy ( + priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } else if (threadRecipient.isOpenGroupRecipient) { + val openGroup = getOpenGroup(threadID) ?: return + val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return + val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( + priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } + } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } + override fun isPinned(threadID: Long): Boolean { + val threadDB = DatabaseComponent.get(context).threadDatabase() + return threadDB.isPinned(threadID) + } + override fun setThreadDate(threadId: Long, newDate: Long) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.setDate(threadId, newDate) + } + + override fun deleteConversation(threadID: Long) { + val recipient = getRecipientForThread(threadID) + val threadDB = DatabaseComponent.get(context).threadDatabase() + val groupDB = DatabaseComponent.get(context).groupDatabase() + threadDB.deleteConversation(threadID) + if (recipient != null) { + if (recipient.isContactRecipient) { + if (recipient.isLocalNumber) return + val contacts = configFactory.contacts ?: return + contacts.upsertContact(recipient.address.serialize()) { + this.priority = PRIORITY_HIDDEN + } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } else if (recipient.isClosedGroupRecipient) { + // TODO: handle closed group + val volatile = configFactory.convoVolatile ?: return + val groups = configFactory.userGroups ?: return + val groupID = recipient.address.toGroupString() + val closedGroup = getGroup(groupID) + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + if (closedGroup != null) { + groupDB.delete(groupID) // TODO: Should we delete the group? (seems odd to leave it) + volatile.eraseLegacyClosedGroup(groupPublicKey) + groups.eraseLegacyGroup(groupPublicKey) + } else { + Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}") + } + } + } + } override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { return PartAuthority.getAttachmentDataUri(attachmentId) @@ -762,6 +1330,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, if (recipient.isBlocked) return + val threadId = getThreadId(recipient) ?: return + val mediaMessage = IncomingMediaMessage( address, sentTimestamp, @@ -780,14 +1350,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.of(message) ) - database.insertSecureDecryptedMessageInbox(mediaMessage, -1, runIncrement = true, runThreadUpdate = true) + database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) } override fun insertMessageRequestResponse(response: MessageRequestResponse) { val userPublicKey = getUserPublicKey() val senderPublicKey = response.sender!! val recipientPublicKey = response.recipient!! - if (userPublicKey == null || (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)) return + + if ( + userPublicKey == null + || (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey) + // this is true if it is a sync message + || (userPublicKey == recipientPublicKey && userPublicKey == senderPublicKey) + ) return + val recipientDb = DatabaseComponent.get(context).recipientDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase() if (userPublicKey == senderPublicKey) { @@ -799,7 +1376,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val mmsDb = DatabaseComponent.get(context).mmsDatabase() val smsDb = DatabaseComponent.get(context).smsDatabase() val sender = Recipient.from(context, fromSerialized(senderPublicKey), false) - val threadId = threadDB.getOrCreateThreadIdFor(sender) + val threadId = getOrCreateThreadIdFor(sender.address) val profile = response.profile if (profile != null) { val profileManager = SSKEnvironment.shared.profileManager @@ -814,9 +1391,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey)) if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { - profileManager.setProfileKey(context, sender, newProfileKey!!) + profileManager.setProfilePicture(context, sender, profile.profilePictureURL!!, newProfileKey!!) profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN) - profileManager.setProfilePictureURL(context, sender, profile.profilePictureURL!!) } } threadDB.setHasSent(threadId, true) @@ -873,16 +1449,28 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.absent(), Optional.absent() ) - mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true) + mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true) } } + override fun getRecipientApproved(address: Address): Boolean { + return DatabaseComponent.get(context).recipientDatabase().getApproved(address) + } + override fun setRecipientApproved(recipient: Recipient, approved: Boolean) { DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved) + if (recipient.isLocalNumber || !recipient.isContactRecipient) return + configFactory.contacts?.upsertContact(recipient.address.serialize()) { + this.approved = approved + } } override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) { DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe) + if (recipient.isLocalNumber || !recipient.isContactRecipient) return + configFactory.contacts?.upsertContact(recipient.address.serialize()) { + this.approvedMe = approvedMe + } } override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { @@ -1012,9 +1600,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms)) } - override fun unblock(toUnblock: Iterable) { + override fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean) { val recipientDb = DatabaseComponent.get(context).recipientDatabase() - recipientDb.setBlocked(toUnblock, false) + recipientDb.setBlocked(recipients, isBlocked) + recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient -> + configFactory.contacts?.upsertContact(recipient.address.serialize()) { + this.blocked = isBlocked + } + } + val contactsConfig = configFactory.contacts ?: return + if (contactsConfig.needsPush() && !fromConfigUpdate) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } } override fun blockedContacts(): List { 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 52d914af08..5044529981 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -64,7 +64,6 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.util.SessionMetaProtocol; import java.io.Closeable; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; @@ -74,6 +73,11 @@ import java.util.Set; public class ThreadDatabase extends Database { + public interface ConversationThreadUpdateListener { + void threadCreated(@NonNull Address address, long threadId); + void threadDeleted(@NonNull Address address, long threadId); + } + private static final String TAG = ThreadDatabase.class.getSimpleName(); private final Map addressCache = new HashMap<>(); @@ -141,10 +145,16 @@ public class ThreadDatabase extends Database { "ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;"; } + private ConversationThreadUpdateListener updateListener; + public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); } + public void setUpdateListener(ConversationThreadUpdateListener updateListener) { + this.updateListener = updateListener; + } + private long createThreadForRecipient(Address address, boolean group, int distributionType) { ContentValues contentValues = new ContentValues(4); long date = SnodeAPI.getNowWithOffset(); @@ -207,10 +217,14 @@ public class ThreadDatabase extends Database { } private void deleteThread(long threadId) { + Recipient recipient = getRecipientForThreadId(threadId); SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); + int numberRemoved = db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); addressCache.remove(threadId); notifyConversationListListeners(); + if (updateListener != null && numberRemoved > 0 && recipient != null) { + updateListener.threadDeleted(recipient.getAddress(), threadId); + } } private void deleteThreads(Set threadIds) { @@ -278,7 +292,7 @@ public class ThreadDatabase extends Database { DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); - update(threadId, false); + update(threadId, false, true); notifyConversationListeners(threadId); } } finally { @@ -291,10 +305,34 @@ public class ThreadDatabase extends Database { Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); - update(threadId, false); + update(threadId, false, true); notifyConversationListeners(threadId); } + public List setRead(long threadId, long lastReadTime) { + + final List smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime); + final List mmsRecords = DatabaseComponent.get(context).mmsDatabase().setMessagesRead(threadId, lastReadTime); + + if (smsRecords.isEmpty() && mmsRecords.isEmpty()) { + return Collections.emptyList(); + } + + ContentValues contentValues = new ContentValues(2); + contentValues.put(READ, smsRecords.isEmpty() && mmsRecords.isEmpty()); + contentValues.put(LAST_SEEN, lastReadTime); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); + + notifyConversationListListeners(); + + return new LinkedList() {{ + addAll(smsRecords); + addAll(mmsRecords); + }}; + } + public List setRead(long threadId, boolean lastSeen) { ContentValues contentValues = new ContentValues(1); contentValues.put(READ, 1); @@ -319,30 +357,6 @@ public class ThreadDatabase extends Database { }}; } - public void incrementUnread(long threadId, int amount, int unreadMentionAmount) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " + - UNREAD_COUNT + " = " + UNREAD_COUNT + " + ?, " + - UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " + ? WHERE " + ID + " = ?", - new String[] { - String.valueOf(amount), - String.valueOf(unreadMentionAmount), - String.valueOf(threadId) - }); - } - - public void decrementUnread(long threadId, int amount, int unreadMentionAmount) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " + - UNREAD_COUNT + " = " + UNREAD_COUNT + " - ?, " + - UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " - ? WHERE " + ID + " = ? AND " + UNREAD_COUNT + " > 0", - new String[] { - String.valueOf(amount), - String.valueOf(unreadMentionAmount), - String.valueOf(threadId) - }); - } - public void setDistributionType(long threadId, int distributionType) { ContentValues contentValues = new ContentValues(1); contentValues.put(TYPE, distributionType); @@ -352,6 +366,14 @@ public class ThreadDatabase extends Database { notifyConversationListListeners(); } + public void setDate(long threadId, long date) { + ContentValues contentValues = new ContentValues(1); + contentValues.put(DATE, date); + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); + if (updated > 0) notifyConversationListListeners(); + } + public int getDistributionType(long threadId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); @@ -427,9 +449,9 @@ public class ThreadDatabase extends Database { " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + - " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + MESSAGE_COUNT + " = " + UNREAD_COUNT + " AND " + - RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + + " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; cursor = db.rawQuery(query, null); @@ -481,7 +503,7 @@ public class ThreadDatabase extends Database { } public Cursor getApprovedConversationList() { - String where = "((" + MESSAGE_COUNT + " != 0 AND (" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%')) OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + "AND " + ARCHIVED + " = 0 "; return getConversationList(where); } @@ -517,21 +539,50 @@ public class ThreadDatabase extends Database { return db.rawQuery(query, null); } - public void setLastSeen(long threadId, long timestamp) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - ContentValues contentValues = new ContentValues(1); - if (timestamp == -1) { - contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); - } else { - contentValues.put(LAST_SEEN, timestamp); - } + /** + * @param threadId + * @param timestamp + * @return true if we have set the last seen for the thread, false if there were no messages in the thread + */ + public boolean setLastSeen(long threadId, long timestamp) { + // edge case where we set the last seen time for a conversation before it loads messages (joining community for example) + MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); + Recipient forThreadId = getRecipientForThreadId(threadId); + if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isOpenGroupRecipient()) return false; + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues contentValues = new ContentValues(1); + long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp; + contentValues.put(LAST_SEEN, lastSeenTime); + db.beginTransaction(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); + String smsCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0"; + String smsMentionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0 AND s."+SmsDatabase.HAS_MENTION+" = 1"; + String smsReactionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.REACTIONS_UNREAD+" = 1"; + String mmsCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0"; + String mmsMentionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0 AND m."+MmsDatabase.HAS_MENTION+" = 1"; + String mmsReactionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.REACTIONS_UNREAD+" = 1"; + String allSmsUnread = "(("+smsCountSubQuery+") + ("+smsReactionCountSubQuery+"))"; + String allMmsUnread = "(("+mmsCountSubQuery+") + ("+mmsReactionCountSubQuery+"))"; + String allUnread = "(("+allSmsUnread+") + ("+allMmsUnread+"))"; + String allUnreadMention = "(("+smsMentionCountSubQuery+") + ("+mmsMentionCountSubQuery+"))"; + + String reflectUpdates = "UPDATE "+TABLE_NAME+" AS t SET "+UNREAD_COUNT+" = "+allUnread+", "+UNREAD_MENTION_COUNT+" = "+allUnreadMention+" WHERE "+ID+" = ?"; + db.execSQL(reflectUpdates, new Object[]{threadId}); + db.setTransactionSuccessful(); + db.endTransaction(); + notifyConversationListeners(threadId); notifyConversationListListeners(); + return true; } - public void setLastSeen(long threadId) { - setLastSeen(threadId, -1); + /** + * @param threadId + * @return true if we have set the last seen for the thread, false if there were no messages in the thread + */ + public boolean setLastSeen(long threadId) { + return setLastSeen(threadId, -1); } public Pair getLastSeenAndHasSent(long threadId) { @@ -634,13 +685,19 @@ public class ThreadDatabase extends Database { try { cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null); - + long threadId; + boolean created = false; if (cursor != null && cursor.moveToFirst()) { - return cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); } else { DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true); - return createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType); + threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType); + created = true; } + if (created && updateListener != null) { + updateListener.threadCreated(recipient.getAddress(), threadId); + } + return threadId; } finally { if (cursor != null) cursor.close(); @@ -679,13 +736,14 @@ public class ThreadDatabase extends Database { new String[] {String.valueOf(threadId)}); notifyConversationListeners(threadId); + notifyConversationListListeners(); } - public boolean update(long threadId, boolean unarchive) { + public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); long count = mmsSmsDatabase.getConversationCount(threadId); - boolean shouldDeleteEmptyThread = deleteThreadOnEmpty(threadId); + boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId); if (count == 0 && shouldDeleteEmptyThread) { deleteThread(threadId); @@ -708,12 +766,10 @@ public class ThreadDatabase extends Database { updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); - notifyConversationListListeners(); return false; } else { if (shouldDeleteEmptyThread) { deleteThread(threadId); - notifyConversationListListeners(); return true; } return false; @@ -721,6 +777,8 @@ public class ThreadDatabase extends Database { } finally { if (reader != null) reader.close(); + notifyConversationListListeners(); + notifyConversationListeners(threadId); } } @@ -732,10 +790,32 @@ public class ThreadDatabase extends Database { new String[] {String.valueOf(threadId)}); notifyConversationListeners(threadId); + notifyConversationListListeners(); } - public void markAllAsRead(long threadId, boolean isGroupRecipient) { - List messages = setRead(threadId, true); + public boolean isPinned(long threadId) { + SQLiteDatabase db = getReadableDatabase(); + Cursor cursor = db.query(TABLE_NAME, new String[]{IS_PINNED}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); + try { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0) == 1; + } + return false; + } finally { + if (cursor != null) cursor.close(); + } + } + + /** + * @param threadId + * @param isGroupRecipient + * @param lastSeenTime + * @return true if we have set the last seen for the thread, false if there were no messages in the thread + */ + public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastSeenTime, boolean force) { + MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); + if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false; + List messages = setRead(threadId, lastSeenTime); if (isGroupRecipient) { for (MarkedMessageInfo message: messages) { MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); @@ -743,7 +823,8 @@ public class ThreadDatabase extends Database { } else { MarkReadReceiver.process(context, messages); } - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, false, 0); + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId); + return setLastSeen(threadId, lastSeenTime); } private boolean deleteThreadOnEmpty(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 8a4473b409..89bda09948 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 @@ -11,7 +11,6 @@ import androidx.core.app.NotificationCompat; import net.zetetic.database.sqlcipher.SQLiteConnection; import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabaseHook; -import net.zetetic.database.sqlcipher.SQLiteException; import net.zetetic.database.sqlcipher.SQLiteOpenHelper; import org.session.libsession.utilities.TextSecurePreferences; @@ -19,6 +18,7 @@ import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; +import org.thoughtcrime.securesms.database.ConfigDatabase; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; @@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.database.SessionJobDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities; import java.io.File; @@ -85,9 +86,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV38 = 59; private static final int lokiV39 = 60; private static final int lokiV40 = 61; + private static final int lokiV41 = 62; + private static final int lokiV42 = 63; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV40; + private static final int DATABASE_VERSION = lokiV42; private static final int MIN_DATABASE_VERSION = lokiV7; private static final String CIPHER3_DATABASE_NAME = "signal.db"; public static final String DATABASE_NAME = "signal_v4.db"; @@ -147,7 +150,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { connection.execute("PRAGMA cipher_page_size = 4096;", null, null); } - private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) throws SQLiteException { + private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) { return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() { @Override public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); } @@ -340,6 +343,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(ThreadDatabase.getUnreadMentionCountCommand()); db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); + db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND); executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS); @@ -351,6 +355,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { executeStatements(db, ReactionDatabase.CREATE_INDEXS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); + db.execSQL(RecipientDatabase.getAddWrapperHash()); } @Override @@ -583,6 +588,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); } + if (oldVersion < lokiV41) { + db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND); + db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_GROUPS); + db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_ONE_TO_ONES); + } + + if (oldVersion < lokiV42) { + db.execSQL(RecipientDatabase.getAddWrapperHash()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index 6f26c6ae3a..936e4f287f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies import dagger.Binds import dagger.Module +import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.session.libsession.utilities.AppTextSecurePreferences @@ -19,4 +20,10 @@ abstract class AppModule { @Binds abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AppComponent { + fun getPrefs(): TextSecurePreferences } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt new file mode 100644 index 0000000000..48eda45001 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -0,0 +1,251 @@ +package org.thoughtcrime.securesms.dependencies + +import android.content.Context +import android.os.Trace +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigFactoryUpdateListener +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.ConfigDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities + +class ConfigFactory( + private val context: Context, + private val configDatabase: ConfigDatabase, + private val maybeGetUserInfo: () -> Pair? +) : + ConfigFactoryProtocol { + companion object { + // This is a buffer period within which we will process messages which would result in a + // config change, any message which would normally result in a config change which was sent + // before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have + // it's changes applied (control text will still be added though) + val configChangeBufferPeriod: Long = (2 * 60 * 1000) + } + + fun keyPairChanged() { // this should only happen restoring or clearing data + _userConfig?.free() + _contacts?.free() + _convoVolatileConfig?.free() + _userConfig = null + _contacts = null + _convoVolatileConfig = null + } + + private val userLock = Object() + private var _userConfig: UserProfile? = null + private val contactsLock = Object() + private var _contacts: Contacts? = null + private val convoVolatileLock = Object() + private var _convoVolatileConfig: ConversationVolatileConfig? = null + private val userGroupsLock = Object() + private var _userGroups: UserGroupsConfig? = null + + private val isConfigForcedOn = TextSecurePreferences.hasForcedNewConfig(context) + + private val listeners: MutableList = mutableListOf() + fun registerListener(listener: ConfigFactoryUpdateListener) { + listeners += listener + } + + fun unregisterListener(listener: ConfigFactoryUpdateListener) { + listeners -= listener + } + + private inline fun synchronizedWithLog(lock: Any, body: ()->T): T { + Trace.beginSection("synchronizedWithLog") + val result = synchronized(lock) { + body() + } + Trace.endSection() + return result + } + + override val user: UserProfile? + get() = synchronizedWithLog(userLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_userConfig == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val userDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.USER_PROFILE.name, + publicKey + ) + _userConfig = if (userDump != null) { + UserProfile.newInstance(secretKey, userDump) + } else { + ConfigurationMessageUtilities.generateUserProfileConfigDump()?.let { dump -> + UserProfile.newInstance(secretKey, dump) + } ?: UserProfile.newInstance(secretKey) + } + } + _userConfig + } + + override val contacts: Contacts? + get() = synchronizedWithLog(contactsLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_contacts == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val contactsDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.CONTACTS.name, + publicKey + ) + _contacts = if (contactsDump != null) { + Contacts.newInstance(secretKey, contactsDump) + } else { + ConfigurationMessageUtilities.generateContactConfigDump()?.let { dump -> + Contacts.newInstance(secretKey, dump) + } ?: Contacts.newInstance(secretKey) + } + } + _contacts + } + + override val convoVolatile: ConversationVolatileConfig? + get() = synchronizedWithLog(convoVolatileLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_convoVolatileConfig == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val convoDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name, + publicKey + ) + _convoVolatileConfig = if (convoDump != null) { + ConversationVolatileConfig.newInstance(secretKey, convoDump) + } else { + ConfigurationMessageUtilities.generateConversationVolatileDump(context) + ?.let { dump -> + ConversationVolatileConfig.newInstance(secretKey, dump) + } ?: ConversationVolatileConfig.newInstance(secretKey) + } + } + _convoVolatileConfig + } + + override val userGroups: UserGroupsConfig? + get() = synchronizedWithLog(userGroupsLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_userGroups == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val userGroupsDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.GROUPS.name, + publicKey + ) + _userGroups = if (userGroupsDump != null) { + UserGroupsConfig.Companion.newInstance(secretKey, userGroupsDump) + } else { + ConfigurationMessageUtilities.generateUserGroupDump(context)?.let { dump -> + UserGroupsConfig.Companion.newInstance(secretKey, dump) + } ?: UserGroupsConfig.newInstance(secretKey) + } + } + _userGroups + } + + override fun getUserConfigs(): List = + listOfNotNull(user, contacts, convoVolatile, userGroups) + + + private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) { + val dumped = user?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp) + } + + private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) { + val dumped = contacts?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp) + } + + private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) { + val dumped = convoVolatile?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig( + SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name, + publicKey, + dumped, + timestamp + ) + } + + private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) { + val dumped = userGroups?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp) + } + + override fun persist(forConfigObject: ConfigBase, timestamp: Long) { + try { + listeners.forEach { listener -> + listener.notifyUpdates(forConfigObject) + } + when (forConfigObject) { + is UserProfile -> persistUserConfigDump(timestamp) + is Contacts -> persistContactsConfigDump(timestamp) + is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp) + is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp) + else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet") + } + } catch (e: Exception) { + Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e) + } + } + + override fun conversationInConfig( + publicKey: String?, + groupPublicKey: String?, + openGroupId: String?, + visibleOnly: Boolean + ): Boolean { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true + + val (_, userPublicKey) = maybeGetUserInfo() ?: return true + + if (openGroupId != null) { + val userGroups = userGroups ?: return false + val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context) + val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false + + // Not handling the `hidden` behaviour for communities so just indicate the existence + return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null) + } + else if (groupPublicKey != null) { + val userGroups = userGroups ?: return false + + // Not handling the `hidden` behaviour for legacy groups so just indicate the existence + return (userGroups.getLegacyGroupInfo(groupPublicKey) != null) + } + else if (publicKey == userPublicKey) { + val user = user ?: return false + + return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN) + } + else if (publicKey != null) { + val contacts = contacts ?: return false + val targetContact = contacts.get(publicKey) ?: return false + + return (!visibleOnly || targetContact.priority != ConfigBase.PRIORITY_HIDDEN) + } + + return false + } + + override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true + + val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) + + // Ensure the change occurred after the last config message was handled (minus the buffer period) + return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index 60d31a19d4..f2c046e0aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -45,4 +45,5 @@ interface DatabaseComponent { fun attachmentProvider(): MessageDataProvider fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun groupMemberDatabase(): GroupMemberDatabase + fun configDatabase(): ConfigDatabase } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 3372e10330..524100190e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -6,7 +6,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import net.zetetic.database.sqlcipher.SQLiteDatabase import org.session.libsession.database.MessageDataProvider import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.crypto.AttachmentSecret @@ -132,10 +131,18 @@ object DatabaseModule { @Provides @Singleton - fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper) + fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { + val storage = Storage(context,openHelper, configFactory) + threadDatabase.setUpdateListener(storage) + return storage + } @Provides @Singleton fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) + @Provides + @Singleton + fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): ConfigDatabase = ConfigDatabase(context, openHelper) + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java deleted file mode 100644 index 033b3ef45a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.thoughtcrime.securesms.dependencies; - -public interface InjectableType { -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt new file mode 100644 index 0000000000..cd4b071338 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.dependencies + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.session.libsession.utilities.ConfigFactoryUpdateListener +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.ConfigDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SessionUtilModule { + + private fun maybeUserEdSecretKey(context: Context): ByteArray? { + val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null + return edKey.secretKey.asBytes + } + + @Provides + @Singleton + fun provideConfigFactory(@ApplicationContext context: Context, configDatabase: ConfigDatabase): ConfigFactory = + ConfigFactory(context, configDatabase) { + val localUserPublicKey = TextSecurePreferences.getLocalNumber(context) + val secretKey = maybeUserEdSecretKey(context) + if (localUserPublicKey == null || secretKey == null) null + else secretKey to localUserPublicKey + }.apply { + registerListener(context as ConfigFactoryUpdateListener) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt index 8b880d2189..74e2cac4c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt @@ -98,7 +98,7 @@ class NewMessageFragment : Fragment() { private fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt new file mode 100644 index 0000000000..8b362d70d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import network.loki.messenger.libsession_util.ConfigBase +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI +import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.dependencies.ConfigFactory + +object ClosedGroupManager { + + fun silentlyRemoveGroup(context: Context, threadId: Long, groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean = true) { + val storage = MessagingModuleConfiguration.shared.storage + // Mark the group as inactive + storage.setActive(groupID, false) + storage.removeClosedGroupPublicKey(groupPublicKey) + // Remove the key pairs + storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + storage.removeMember(groupID, Address.fromSerialized(userPublicKey)) + // Notify the PN server + PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) + // Stop polling + ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + storage.cancelPendingMessageSendJobs(threadId) + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) + if (delete) { + storage.deleteConversation(threadId) + } + } + + fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean { + val groups = userGroups ?: return false + if (!group.isClosedGroup) return false + val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) + return groups.eraseLegacyGroup(groupPublicKey) + } + + fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) { + val groups = userGroups ?: return + if (!group.isClosedGroup) return + val storage = MessagingModuleConfiguration.shared.storage + val threadId = storage.getThreadId(group.encodedId) ?: return + val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) + val latestKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return + val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey) + val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize)) + val toSet = legacyInfo.copy( + members = latestMemberMap, + name = group.title, + disappearingTimer = groupRecipientSettings.expireMessages.toLong(), + priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = latestKeyPair.privateKey.serialize() + ) + groups.set(toSet) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt index 62e762316b..9fee8adafc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -16,6 +16,7 @@ import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task @@ -28,16 +29,28 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut import java.io.IOException +import javax.inject.Inject +@AndroidEntryPoint class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { + + @Inject + lateinit var groupConfigFactory: ConfigFactory + @Inject + lateinit var storage: Storage + private val originalMembers = HashSet() private val zombies = HashSet() private val members = HashSet() @@ -289,7 +302,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { isLoading = true loaderContainer.fadeIn() val promise: Promise = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { - MessageSender.explicitLeave(groupPublicKey!!, true) + MessageSender.explicitLeave(groupPublicKey!!, false) } else { task { if (hasNameChanged) { @@ -306,6 +319,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { promise.successUi { loaderContainer.fadeOut() isLoading = false + updateGroupConfig() finish() }.failUi { exception -> val message = if (exception is MessageSender.Error) exception.description else "An error occurred" @@ -316,5 +330,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } } - class GroupMembers(val members: List, val zombieMembers: List) { } + private fun updateGroupConfig() { + val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID)) + ?: return Log.w("Loki", "No recipient settings when trying to update group config") + val latestGroup = storage.getGroup(groupID) + ?: return Log.w("Loki", "No group record when trying to update group config") + groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup) + } + + class GroupMembers(val members: List, val zombieMembers: List) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index a3d0e6d252..d4c5acf4ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -6,6 +6,7 @@ import android.graphics.Bitmap; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.GroupUtil; @@ -16,11 +17,14 @@ import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.util.BitmapUtil; +import java.io.IOException; import java.util.HashSet; import java.util.LinkedList; import java.util.Objects; import java.util.Set; +import network.loki.messenger.libsession_util.UserGroupsConfig; + public class GroupManager { public static long getOpenGroupThreadID(String id, @NonNull Context context) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index d37b17ef9f..ae59c3833e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -55,7 +55,7 @@ class JoinCommunityFragment : Fragment() { fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } @@ -79,7 +79,7 @@ class JoinCommunityFragment : Fragment() { val openGroupID = "$sanitizedServer.${openGroup.room}" OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) val storage = MessagingModuleConfiguration.shared.storage - storage.onOpenGroupAdded(sanitizedServer) + storage.onOpenGroupAdded(sanitizedServer, openGroup.room) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index dbdf2615ae..2754c70f69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -9,8 +9,8 @@ import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import java.util.concurrent.Executors object OpenGroupManager { @@ -40,7 +40,13 @@ object OpenGroupManager { if (isPolling) { return } isPolling = true val storage = MessagingModuleConfiguration.shared.storage - val servers = storage.getAllOpenGroups().values.map { it.server }.toSet() + val (serverGroups, toDelete) = storage.getAllOpenGroups().values.partition { storage.getThreadId(it) != null } + toDelete.forEach { openGroup -> + Log.w("Loki", "Need to delete a group") + delete(openGroup.server, openGroup.room, MessagingModuleConfiguration.shared.context) + } + + val servers = serverGroups.map { it.server }.toSet() synchronized(pollUpdaterLock) { servers.forEach { server -> pollers[server]?.stop() // Shouldn't be necessary @@ -58,14 +64,14 @@ object OpenGroupManager { } @WorkerThread - fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? { + fun add(server: String, room: String, publicKey: String, context: Context): Pair { val openGroupID = "$server.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val storage = MessagingModuleConfiguration.shared.storage val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() // Check it it's added already val existingOpenGroup = threadDB.getOpenGroupChat(threadID) - if (existingOpenGroup != null) { return null } + if (existingOpenGroup != null) { return threadID to null } // Clear any existing data if needed storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -86,7 +92,7 @@ object OpenGroupManager { pollInfo = info.toPollInfo(), createGroupIfMissingWithPublicKey = publicKey ) - return info + return threadID to info } fun restartPollerForServer(server: String) { @@ -102,23 +108,27 @@ object OpenGroupManager { } } + @WorkerThread fun delete(server: String, room: String, context: Context) { val storage = MessagingModuleConfiguration.shared.storage + val configFactory = MessagingModuleConfiguration.shared.configFactory val threadDB = DatabaseComponent.get(context).threadDatabase() - val openGroupID = "$server.$room" + val openGroupID = "${server.removeSuffix("/")}.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val recipient = threadDB.getRecipientForThreadId(threadID) ?: return threadDB.setThreadArchived(threadID) val groupID = recipient.address.serialize() // Stop the poller if needed val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } - if (openGroups.count() == 1) { + if (openGroups.isNotEmpty()) { synchronized(pollUpdaterLock) { val poller = pollers[server] poller?.stop() pollers.remove(server) } } + configFactory.userGroups?.eraseCommunity(server, room) + configFactory.convoVolatile?.eraseCommunity(server, room) // Delete storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -126,19 +136,19 @@ object OpenGroupManager { storage.removeLastOutboxMessageId(server) val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() lokiThreadDB.removeOpenGroupChat(threadID) - ThreadUtils.queue { - threadDB.deleteConversation(threadID) // Must be invoked on a background thread - GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread - } + storage.deleteConversation(threadID) // Must be invoked on a background thread + GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } + @WorkerThread fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? { val url = HttpUrl.parse(urlAsString) ?: return null val server = OpenGroup.getServer(urlAsString) val room = url.pathSegments().firstOrNull() ?: return null val publicKey = url.queryParameter("public_key") ?: return null - return add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function + return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function } fun updateOpenGroup(openGroup: OpenGroup, context: Context) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 7e9d2640a1..702bf33929 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -7,10 +7,15 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.getConversationUnread +import javax.inject.Inject +@AndroidEntryPoint class ConversationOptionsBottomSheet(private val parentContext: Context) : BottomSheetDialogFragment(), View.OnClickListener { private lateinit var binding: FragmentConversationBottomSheetBinding //FIXME AC: Supplying a threadRecord directly into the field from an activity @@ -19,6 +24,8 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto // if we want to use dialog fragments properly. lateinit var thread: ThreadRecord + @Inject lateinit var configFactory: ConfigFactory + var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null var onPinTapped: (() -> Unit)? = null @@ -77,7 +84,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.setOnClickListener(this) binding.deleteTextView.setOnClickListener(this) - binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 + binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true binding.markAllAsReadTextView.setOnClickListener(this) binding.pinTextView.isVisible = !thread.isPinned binding.unpinTextView.isVisible = thread.isPinned diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index c6a6e1f7f5..8d5acb244d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -6,12 +6,12 @@ import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.util.AttributeSet import android.util.TypedValue -import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.recipients.Recipient @@ -19,12 +19,19 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.hig import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.getAccentColor +import org.thoughtcrime.securesms.util.getConversationUnread import java.util.Locale +import javax.inject.Inject +@AndroidEntryPoint class ConversationView : LinearLayout { + + @Inject lateinit var configFactory: ConfigFactory + private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels var thread: ThreadRecord? = null @@ -71,7 +78,7 @@ class ConversationView : LinearLayout { // This would also not trigger the disappearing message timer which may or may not be desirable binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE } - val formattedUnreadCount = if (thread.isRead) { + val formattedUnreadCount = if (unreadCount == 0) { null } else { if (unreadCount < 10000) unreadCount.toString() else "9999+" @@ -80,6 +87,7 @@ class ConversationView : LinearLayout { val textSize = if (unreadCount < 1000) 12.0f else 10.0f binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) + || (configFactory.convoVolatile?.getConversationUnread(thread) == true) binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup) val senderDisplayName = getUserDisplayName(thread.recipient) @@ -128,7 +136,7 @@ class ConversationView : LinearLayout { return if (recipient.isLocalNumber) { context.getString(R.string.note_to_self) } else { - recipient.name // Internally uses the Contact API + recipient.toShortString() // Internally uses the Contact API } } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 700eb167d5..e78b4388f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -1,18 +1,22 @@ package org.thoughtcrime.securesms.home +import android.Manifest +import android.app.NotificationManager import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.ClipData -import android.content.ClipboardManager import android.os.Bundle import android.text.SpannableString import android.widget.Toast import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -26,11 +30,14 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding import network.loki.messenger.databinding.ViewMessageRequestBannerBinding +import network.loki.messenger.libsession_util.ConfigBase import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfilePictureModifiedEvent @@ -48,8 +55,10 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter @@ -60,6 +69,7 @@ import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.onboarding.SeedActivity import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showMuteDialog @@ -80,6 +90,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), SeedReminderViewDelegate, GlobalSearchInputLayout.GlobalSearchInputLayoutListener { + companion object { + const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" + } + + private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests private var broadcastReceiver: BroadcastReceiver? = null @@ -87,8 +102,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var recipientDatabase: RecipientDatabase + @Inject lateinit var storage: Storage @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences + @Inject lateinit var configFactory: ConfigFactory private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -97,7 +114,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), get() = textSecurePreferences.getLocalNumber()!! private val homeAdapter: HomeAdapter by lazy { - HomeAdapter(context = this, listener = this) + HomeAdapter(context = this, configFactory = configFactory, listener = this) } private val globalSearchAdapter = GlobalSearchAdapter { model -> @@ -158,15 +175,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } binding.sessionToolbar.disableClipping() // Set up seed reminder view - val hasViewedSeed = textSecurePreferences.getHasViewedSeed() - if (!hasViewedSeed) { - binding.seedReminderView.isVisible = true - binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated - binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) - binding.seedReminderView.setProgress(80, false) - binding.seedReminderView.delegate = this@HomeActivity - } else { - binding.seedReminderView.isVisible = false + lifecycleScope.launchWhenStarted { + val hasViewedSeed = textSecurePreferences.getHasViewedSeed() + if (!hasViewedSeed) { + binding.seedReminderView.isVisible = true + binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated + binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) + binding.seedReminderView.setProgress(80, false) + binding.seedReminderView.delegate = this@HomeActivity + } else { + binding.seedReminderView.isVisible = false + } } setupMessageRequestsBanner() // Set up recycler view @@ -176,6 +195,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.recyclerView.adapter = homeAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter + binding.configOutdatedView.setOnClickListener { + textSecurePreferences.setHasLegacyConfig(false) + updateLegacyConfigView() + } + // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } IP2Country.configureIfNeeded(this@HomeActivity) @@ -192,6 +216,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), this.broadcastReceiver = broadcastReceiver LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) + // subscribe to outdated config updates, this should be removed after long enough time for device migration + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + TextSecurePreferences.events.filter { it == TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG }.collect { + updateLegacyConfigView() + } + } + } + lifecycleScope.launchWhenStarted { launch(Dispatchers.IO) { // Double check that the long poller is up @@ -212,6 +245,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } } + // monitor the global search VM query launch { binding.globalSearchInputLayout.query @@ -264,6 +298,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } EventBus.getDefault().register(this@HomeActivity) + if (intent.hasExtra(FROM_ONBOARDING) + && intent.getBooleanExtra(FROM_ONBOARDING, false) + && !(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled() + ) { + Permissions.with(this) + .request(Manifest.permission.POST_NOTIFICATIONS) + .execute() + } } override fun onInputFocusChanged(hasFocus: Boolean) { @@ -312,6 +354,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } + private fun updateLegacyConfigView() { + binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset) + && textSecurePreferences.getHasLegacyConfig() + } + override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) @@ -322,6 +369,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), if (textSecurePreferences.getHasViewedSeed()) { binding.seedReminderView.isVisible = false } + + updateLegacyConfigView() + + // TODO: remove this after enough updates that we can rely on ConfigBase.isNewConfigEnabled to always return true + // This will only run if we aren't using new configs, as they are schedule to sync when there are changes applied if (textSecurePreferences.getConfigurationMessageSynced()) { lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) @@ -493,9 +545,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) button(R.string.RecipientPreferenceActivity_block) { lifecycleScope.launch(Dispatchers.IO) { - recipientDatabase.setBlocked(thread.recipient, true) - // TODO: Remove in UserConfig branch - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) + storage.setBlocked(listOf(thread.recipient), true) + withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } @@ -511,9 +562,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) button(R.string.RecipientPreferenceActivity_unblock) { lifecycleScope.launch(Dispatchers.IO) { - recipientDatabase.setBlocked(thread.recipient, false) - // TODO: Remove in UserConfig branch - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) + storage.setBlocked(listOf(thread.recipient), false) + withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } @@ -554,14 +604,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.IO) { - threadDb.setPinned(threadId, pinned) + storage.setPinned(threadId, pinned) homeViewModel.tryUpdateChannel() } } private fun markAllAsRead(thread: ThreadRecord) { ThreadUtils.queue { - threadDb.markAllAsRead(thread.threadId, thread.recipient.isOpenGroupRecipient) + MessagingModuleConfiguration.shared.storage.markConversationAsRead(thread.threadId, SnodeAPI.nowWithOffset) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 4273794f5e..eaf242aae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -10,10 +10,12 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import network.loki.messenger.R import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests class HomeAdapter( private val context: Context, + private val configFactory: ConfigFactory, private val listener: ConversationClickListener ) : RecyclerView.Adapter(), ListUpdateCallback { @@ -29,7 +31,7 @@ class HomeAdapter( get() = _data.toList() set(newData) { val previousData = _data.toList() - val diff = HomeDiffUtil(previousData, newData, context) + val diff = HomeDiffUtil(previousData, newData, context, configFactory) val diffResult = DiffUtil.calculateDiff(diff) _data = newData diffResult.dispatchUpdatesTo(this as ListUpdateCallback) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index b883709c0a..0fe93d41de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -3,11 +3,14 @@ package org.thoughtcrime.securesms.home import android.content.Context import androidx.recyclerview.widget.DiffUtil import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.getConversationUnread class HomeDiffUtil( private val old: List, private val new: List, - private val context: Context + private val context: Context, + private val configFactory: ConfigFactory ): DiffUtil.Callback() { override fun getOldListSize(): Int = old.size @@ -42,7 +45,9 @@ class HomeDiffUtil( oldItem.isFailed == newItem.isFailed && oldItem.isDelivered == newItem.isDelivered && oldItem.isSent == newItem.isSent && - oldItem.isPending == newItem.isPending + oldItem.isPending == newItem.isPending && + oldItem.lastSeen == newItem.lastSeen && + configFactory.convoVolatile?.getConversationUnread(newItem) != true ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt index 947bd89b4e..7ab7bfb508 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt @@ -9,9 +9,14 @@ import android.graphics.Paint import android.util.AttributeSet import android.view.View import androidx.annotation.ColorInt +import androidx.lifecycle.coroutineScope import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.snode.OnionRequestAPI +import org.thoughtcrime.securesms.conversation.v2.ViewUtil import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.toPx @@ -29,6 +34,8 @@ class PathStatusView : View { result } + private var updateJob: Job? = null + constructor(context: Context) : super(context) { initialize() } @@ -87,16 +94,21 @@ class PathStatusView : View { private fun handlePathsBuiltEvent() { update() } private fun update() { - if (OnionRequestAPI.paths.isNotEmpty()) { - setBackgroundResource(R.drawable.accent_dot) - val hasPathsColor = context.getColor(R.color.accent_green) - mainColor = hasPathsColor - sessionShadowColor = hasPathsColor - } else { - setBackgroundResource(R.drawable.paths_building_dot) - val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme) - mainColor = pathsBuildingColor - sessionShadowColor = pathsBuildingColor + if (updateJob?.isActive != true) { // false or null + updateJob = ViewUtil.getActivityLifecycle(this)?.coroutineScope?.launchWhenStarted { + val paths = withContext(Dispatchers.IO) { OnionRequestAPI.paths } + if (paths.isNotEmpty()) { + setBackgroundResource(R.drawable.accent_dot) + val hasPathsColor = context.getColor(R.color.accent_green) + mainColor = hasPathsColor + sessionShadowColor = hasPathsColor + } else { + setBackgroundResource(R.drawable.paths_building_dot) + val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme) + mainColor = pathsBuildingColor + sessionShadowColor = pathsBuildingColor + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index bc9a9beced..b37bd886c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -25,9 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideApp -import org.thoughtcrime.securesms.util.UiModeUtilities import javax.inject.Inject @AndroidEntryPoint @@ -61,6 +59,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { profilePictureView.root.update(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { + if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener nameTextViewContainer.visibility = View.INVISIBLE nameEditTextContainer.visibility = View.VISIBLE nicknameEditText.text = null @@ -87,8 +86,14 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { } nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally - publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient - messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED + nameEditIcon.isVisible = threadRecipient.isContactRecipient + && !threadRecipient.isOpenGroupInboxRecipient + && !threadRecipient.isOpenGroupOutboxRecipient + + publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient + && !threadRecipient.isOpenGroupInboxRecipient + && !threadRecipient.isOpenGroupOutboxRecipient + messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true publicKeyTextView.text = publicKey publicKeyTextView.setOnLongClickListener { val clipboard = @@ -130,10 +135,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { newNickName = nicknameEditText.text.toString() } val publicKey = recipient.address.serialize() - val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() - val contact = contactDB.getContactWithSessionID(publicKey) ?: Contact(publicKey) + val storage = MessagingModuleConfiguration.shared.storage + val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey) contact.nickname = newNickName - contactDB.setContact(contact) + storage.setContact(contact) nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 2c64ded866..54f9290ffb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -12,6 +12,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.util.DateUtils @@ -76,6 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { } binding.searchResultSubtitle.text = getHighlight(query, membersString) } + is Header, // do nothing for header + is SavedMessages -> Unit // do nothing for saved messages (displays note to self) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt index 07da14b090..a85ea525ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt @@ -154,7 +154,7 @@ class KeyboardPageSearchView @JvmOverloads constructor( .setDuration(REVEAL_DURATION) .alpha(0f) .setListener(object : AnimationCompleteListener() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { visibility = INVISIBLE } }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 60942fd72b..caecbcd87d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -49,7 +49,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat adapter.glide = glide binding.recyclerView.adapter = adapter - binding.clearAllMessageRequestsButton.setOnClickListener { deleteAllAndBlock() } + binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() } } override fun onResume() { @@ -113,9 +113,9 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat binding.clearAllMessageRequestsButton.isVisible = threadCount != 0 } - private fun deleteAllAndBlock() { + private fun deleteAll() { fun doDeleteAllAndBlock() { - viewModel.clearAllMessageRequests() + viewModel.clearAllMessageRequests(false) LoaderManager.getInstance(this).restartLoader(0, null, this) lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index 89a841dc0a..10142cc8fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -48,6 +48,7 @@ class MessageRequestsAdapter( private fun showPopupMenu(view: MessageRequestView) { val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) + popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient popupMenu.setOnMenuItemClickListener { menuItem -> if (menuItem.itemId == R.id.menu_delete_message_request) { listener.onDeleteConversationClick(view.thread!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt index 2f448932dd..a3a7caf8d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt @@ -25,8 +25,8 @@ class MessageRequestsViewModel @Inject constructor( repository.deleteMessageRequest(thread) } - fun clearAllMessageRequests() = viewModelScope.launch { - repository.clearAllMessageRequests() + fun clearAllMessageRequests(block: Boolean) = viewModelScope.launch { + repository.clearAllMessageRequests(block) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 327492e95c..0157d8ad41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -60,7 +60,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilit import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.database.LokiThreadDatabase; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; @@ -160,8 +159,9 @@ public class DefaultMessageNotifier implements MessageNotifier { executor.cancel(); } - private void cancelActiveNotifications(@NonNull Context context) { + private boolean cancelActiveNotifications(@NonNull Context context) { NotificationManager notifications = ServiceUtil.getNotificationManager(context); + boolean hasNotifications = notifications.getActiveNotifications().length > 0; notifications.cancel(SUMMARY_NOTIFICATION_ID); try { @@ -175,6 +175,7 @@ public class DefaultMessageNotifier implements MessageNotifier { Log.w(TAG, e); notifications.cancelAll(); } + return hasNotifications; } private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { @@ -240,10 +241,6 @@ public class DefaultMessageNotifier implements MessageNotifier { !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { TextSecurePreferences.removeHasHiddenMessageRequests(context); } - if (isVisible && recipient != null) { - List messageIds = threads.setRead(threadId, false); - if (SessionMetaProtocol.shouldSendReadReceipt(recipient)) { MarkReadReceiver.process(context, messageIds); } - } if (!TextSecurePreferences.isNotificationsEnabled(context) || (recipient != null && recipient.isMuted())) @@ -251,11 +248,21 @@ public class DefaultMessageNotifier implements MessageNotifier { return; } - if (!isVisible && !homeScreenVisible) { + if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) { updateNotification(context, signal, 0); } } + private boolean hasExistingNotifications(Context context) { + NotificationManager notifications = ServiceUtil.getNotificationManager(context); + try { + StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); + return activeNotifications.length > 0; + } catch (Exception e) { + return false; + } + } + @Override public void updateNotification(@NonNull Context context, boolean signal, int reminderCount) { @@ -267,8 +274,8 @@ public class DefaultMessageNotifier implements MessageNotifier { if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) { - cancelActiveNotifications(context); updateBadge(context, 0); + cancelActiveNotifications(context); clearReminder(context); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index 6075be65e7..309f2732f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -12,6 +12,8 @@ import androidx.core.app.NotificationManagerCompat; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; +import org.session.libsession.database.StorageProtocol; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.messages.control.ReadReceipt; import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.snode.SnodeAPI; @@ -27,7 +29,6 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.SessionMetaProtocol; -import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -52,18 +53,12 @@ public class MarkReadReceiver extends BroadcastReceiver { new AsyncTask() { @Override protected Void doInBackground(Void... params) { - List messageIdsCollection = new LinkedList<>(); - + long currentTime = SnodeAPI.getNowWithOffset(); for (long threadId : threadIds) { Log.i(TAG, "Marking as read: " + threadId); - List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); - messageIdsCollection.addAll(messageIds); + StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); + storage.markConversationAsRead(threadId,currentTime, true); } - - process(context, messageIdsCollection); - - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context); - return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index 31117ae94d..edd1bc274a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -15,6 +15,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter @@ -34,12 +35,19 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityLinkDeviceBinding internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -112,6 +120,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel val keyPairGenerationResult = KeyPairUtilities.generate(seed) val x25519KeyPair = keyPairGenerationResult.x25519KeyPair KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID) @@ -124,9 +133,8 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel .setAction(R.string.registration_activity__skip) { register(true) } val skipJob = launch { - delay(30_000L) + delay(15_000L) snackBar.show() - // show a dialog or something saying do you want to skip this bit? } // start polling and wait for updated message ApplicationContext.getInstance(this@LinkDeviceActivity).apply { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt index 42f3a85b47..2de6269536 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt @@ -164,6 +164,7 @@ class PNModeActivity : BaseActionBarActivity() { application.registerForFCMIfNeeded(true) val intent = Intent(this, HomeActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtra(HomeActivity.FROM_ONBOARDING, true) show(intent) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt index 5531fea491..051cd7542e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt @@ -11,6 +11,7 @@ import android.text.style.ClickableSpan import android.text.style.StyleSpan import android.view.View import android.widget.Toast +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding import org.session.libsession.snode.SnodeModule @@ -23,10 +24,17 @@ import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityRecoveryPhraseRestoreBinding internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -81,6 +89,7 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { val keyPairGenerationResult = KeyPairUtilities.generate(seed) val x25519KeyPair = keyPairGenerationResult.x25519KeyPair KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this, registrationID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt index b6fdaf9cf9..6e082e0008 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt @@ -16,6 +16,7 @@ import android.text.style.StyleSpan import android.view.View import android.widget.Toast import com.goterl.lazysodium.utils.KeyPair +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityRegisterBinding import org.session.libsession.snode.SnodeModule @@ -26,10 +27,17 @@ import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class RegisterActivity : BaseActionBarActivity() { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityRegisterBinding internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -119,6 +127,7 @@ class RegisterActivity : BaseActionBarActivity() { database.clearReceivedMessageHashValues() KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this, registrationID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt index aa7ee5a42d..16499cc4bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -22,7 +22,7 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { showSessionDialog { title(viewModel.getTitle(this@BlockedContactsActivity)) text(viewModel.getMessage(this@BlockedContactsActivity)) - button(R.string.continue_2) { viewModel.unblock(this@BlockedContactsActivity) } + button(R.string.continue_2) { viewModel.unblock() } cancelButton() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index acbba1ebb2..dbe09668c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -63,13 +63,9 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage) return _state } - fun unblock(context: Context) { - storage.unblock(state.selectedItems) + fun unblock() { + storage.setBlocked(state.selectedItems, false) _state.value = state.copy(selectedItems = emptySet()) - // TODO: Remove in UserConfig branch - GlobalScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } } fun select(selectedItem: Recipient, isSelected: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index badcbe66b8..8c3e6190ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -24,8 +24,6 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.components.CustomDefaultPreference; import org.thoughtcrime.securesms.conversation.v2.ViewUtil; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreference; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreferenceDialogFragmentCompat; import network.loki.messenger.R; @@ -60,9 +58,7 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp public void onDisplayPreferenceDialog(Preference preference) { DialogFragment dialogFragment = null; - if (preference instanceof ColorPickerPreference) { - dialogFragment = ColorPickerPreferenceDialogFragmentCompat.newInstance(preference.getKey()); - } else if (preference instanceof CustomDefaultPreference) { + if (preference instanceof CustomDefaultPreference) { dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 210c88fee7..f3cdc269c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.preferences import android.Manifest import android.app.Activity -import android.content.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent import android.net.Uri import android.os.AsyncTask import android.os.Bundle @@ -17,14 +20,18 @@ import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.view.isVisible +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySettingsBinding +import network.loki.messenger.libsession_util.util.UserPic import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.SnodeAPI import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.utilities.* import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol @@ -32,6 +39,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.components.ProfilePictureView +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.mms.GlideApp @@ -48,9 +56,14 @@ import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import java.io.File import java.security.SecureRandom -import java.util.Date +import javax.inject.Inject +@AndroidEntryPoint class SettingsActivity : PassphraseRequiredActionBarActivity() { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivitySettingsBinding private var displayNameEditActionMode: ActionMode? = null set(value) { field = value; handleDisplayNameEditActionModeChanged() } @@ -76,7 +89,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { glide = GlideApp.with(this) with(binding) { setupProfilePictureView(profilePictureView.root) - profilePictureView.root.setOnClickListener { showEditProfilePictureUI() } + profilePictureView.root.setOnClickListener { + showEditProfilePictureUI() + } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = displayName publicKeyTextView.text = hexEncodedPublicKey @@ -204,27 +219,36 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val promises = mutableListOf>() if (displayName != null) { TextSecurePreferences.setProfileName(this, displayName) + configFactory.user?.setName(displayName) } val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this) if (isUpdatingProfilePicture) { if (profilePicture != null) { promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this)) } else { - TextSecurePreferences.setLastProfilePictureUpload(this, System.currentTimeMillis()) - TextSecurePreferences.setProfilePictureURL(this, null) + MessagingModuleConfiguration.shared.storage.clearUserPic() } } val compoundPromise = all(promises) compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below + val userConfig = configFactory.user if (isUpdatingProfilePicture) { AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 ) - TextSecurePreferences.setLastProfilePictureUpload(this, Date().time) ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) + // new config + val url = TextSecurePreferences.getProfilePictureURL(this) + val profileKey = ProfileKeyUtil.getProfileKey(this) + if (profilePicture == null) { + userConfig?.setPic(UserPic.DEFAULT) + } else if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) { + userConfig?.setPic(UserPic(url, profileKey)) + } } - if (profilePicture != null || displayName != null) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) + if (userConfig != null && userConfig.needsDump()) { + configFactory.persist(userConfig, SnodeAPI.nowWithOffset) } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) } compoundPromise.alwaysUi { if (displayName != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java deleted file mode 100644 index 1cccf1d524..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java +++ /dev/null @@ -1,251 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import androidx.core.content.ContextCompat; -import androidx.core.content.res.TypedArrayUtils; -import androidx.preference.DialogPreference; -import androidx.preference.PreferenceViewHolder; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.widget.ImageView; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.ColorPickerDialog.Size; -import com.takisoft.colorpicker.ColorStateDrawable; - -import network.loki.messenger.R; - -public class ColorPickerPreference extends DialogPreference { - - private static final String TAG = ColorPickerPreference.class.getSimpleName(); - - private int[] colors; - private CharSequence[] colorDescriptions; - private int color; - private int columns; - private int size; - private boolean sortColors; - - private ImageView colorWidget; - private OnPreferenceChangeListener listener; - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - - TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ColorPickerPreference, defStyleAttr, 0); - - int colorsId = a.getResourceId(R.styleable.ColorPickerPreference_colors, R.array.color_picker_default_colors); - - if (colorsId != 0) { - colors = context.getResources().getIntArray(colorsId); - } - - colorDescriptions = a.getTextArray(R.styleable.ColorPickerPreference_colorDescriptions); - color = a.getColor(R.styleable.ColorPickerPreference_currentColor, 0); - columns = a.getInt(R.styleable.ColorPickerPreference_columns, 3); - size = a.getInt(R.styleable.ColorPickerPreference_colorSize, 2); - sortColors = a.getBoolean(R.styleable.ColorPickerPreference_sortColors, false); - - a.recycle(); - - setWidgetLayoutResource(R.layout.preference_widget_color_swatch); - } - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - @SuppressLint("RestrictedApi") - public ColorPickerPreference(Context context, AttributeSet attrs) { - this(context, attrs, TypedArrayUtils.getAttr(context, R.attr.dialogPreferenceStyle, - android.R.attr.dialogPreferenceStyle)); - } - - public ColorPickerPreference(Context context) { - this(context, null); - } - - @Override - public void setOnPreferenceChangeListener(OnPreferenceChangeListener listener) { - super.setOnPreferenceChangeListener(listener); - this.listener = listener; - } - - @Override - public void onBindViewHolder(PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - colorWidget = (ImageView) holder.findViewById(R.id.color_picker_widget); - setColorOnWidget(color); - } - - private void setColorOnWidget(int color) { - if (colorWidget == null) { - return; - } - - Drawable[] colorDrawable = new Drawable[] - {ContextCompat.getDrawable(getContext(), R.drawable.colorpickerpreference_pref_swatch)}; - colorWidget.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); - } - - /** - * Returns the current color. - * - * @return The current color. - */ - public int getColor() { - return color; - } - - /** - * Sets the current color. - * - * @param color The current color. - */ - public void setColor(int color) { - setInternalColor(color, false); - } - - /** - * Returns all of the available colors. - * - * @return The available colors. - */ - public int[] getColors() { - return colors; - } - - /** - * Sets the available colors. - * - * @param colors The available colors. - */ - public void setColors(int[] colors) { - this.colors = colors; - } - - /** - * Returns whether the available colors should be sorted automatically based on their HSV - * values. - * - * @return Whether the available colors should be sorted automatically based on their HSV - * values. - */ - public boolean isSortColors() { - return sortColors; - } - - /** - * Sets whether the available colors should be sorted automatically based on their HSV - * values. The sorting does not modify the order of the original colors supplied via - * {@link #setColors(int[])} or the XML attribute {@code app:colors}. - * - * @param sortColors Whether the available colors should be sorted automatically based on their - * HSV values. - */ - public void setSortColors(boolean sortColors) { - this.sortColors = sortColors; - } - - /** - * Returns the available colors' descriptions that can be used by accessibility services. - * - * @return The available colors' descriptions. - */ - public CharSequence[] getColorDescriptions() { - return colorDescriptions; - } - - /** - * Sets the available colors' descriptions that can be used by accessibility services. - * - * @param colorDescriptions The available colors' descriptions. - */ - public void setColorDescriptions(CharSequence[] colorDescriptions) { - this.colorDescriptions = colorDescriptions; - } - - /** - * Returns the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @return The number of columns to be used in the picker dialog. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public int getColumns() { - return columns; - } - - /** - * Sets the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @param columns The number of columns to be used in the picker dialog. Use 0 to set it to - * 'auto' mode. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public void setColumns(int columns) { - this.columns = columns; - } - - /** - * Returns the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @return The size of the color swatches in the dialog. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - @Size - public int getSize() { - return size; - } - - /** - * Sets the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @param size The size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - public void setSize(@Size int size) { - this.size = size; - } - - private void setInternalColor(int color, boolean force) { - int oldColor = getPersistedInt(0); - - boolean changed = oldColor != color; - - if (changed || force) { - this.color = color; - - persistInt(color); - - setColorOnWidget(color); - - if (listener != null) listener.onPreferenceChange(this, color); - notifyChanged(); - } - } - - @Override - protected Object onGetDefaultValue(TypedArray a, int index) { - return a.getString(index); - } - - @Override - protected void onSetInitialValue(boolean restoreValue, Object defaultValueObj) { - final String defaultValue = (String) defaultValueObj; - setInternalColor(restoreValue ? getPersistedInt(0) : (!TextUtils.isEmpty(defaultValue) ? Color.parseColor(defaultValue) : 0), true); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java deleted file mode 100644 index 964f439ba1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.app.Dialog; -import android.content.DialogInterface; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.preference.PreferenceDialogFragmentCompat; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.OnColorSelectedListener; - -public class ColorPickerPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat implements OnColorSelectedListener { - - private int pickedColor; - - public static ColorPickerPreferenceDialogFragmentCompat newInstance(String key) { - ColorPickerPreferenceDialogFragmentCompat fragment = new ColorPickerPreferenceDialogFragmentCompat(); - Bundle b = new Bundle(1); - b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); - fragment.setArguments(b); - return fragment; - } - - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - ColorPickerPreference pref = getColorPickerPreference(); - - ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(getContext()) - .setSelectedColor(pref.getColor()) - .setColors(pref.getColors()) - .setColorContentDescriptions(pref.getColorDescriptions()) - .setSize(pref.getSize()) - .setSortColors(pref.isSortColors()) - .setColumns(pref.getColumns()) - .build(); - - ColorPickerDialog dialog = new ColorPickerDialog(getActivity(), this, params); - dialog.setTitle(pref.getDialogTitle()); - - return dialog; - } - - @Override - public void onDialogClosed(boolean positiveResult) { - ColorPickerPreference preference = getColorPickerPreference(); - - if (positiveResult) { - preference.setColor(pickedColor); - } - } - - @Override - public void onColorSelected(int color) { - this.pickedColor = color; - - super.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); - } - - ColorPickerPreference getColorPickerPreference() { - return (ColorPickerPreference) getPreference(); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 7c1e036dab..dd013afa74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -23,9 +23,11 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SessionJobDatabase import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -62,7 +64,7 @@ interface ConversationRepository { suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf - suspend fun clearAllMessageRequests(): ResultOf + suspend fun clearAllMessageRequests(block: Boolean): ResultOf suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf @@ -82,8 +84,10 @@ class DefaultConversationRepository @Inject constructor( private val mmsDb: MmsDatabase, private val mmsSmsDb: MmsSmsDatabase, private val recipientDb: RecipientDatabase, + private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, - private val sessionJobDb: SessionJobDatabase + private val sessionJobDb: SessionJobDatabase, + private val configFactory: ConfigFactory ) : ConversationRepository { override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { @@ -125,8 +129,9 @@ class DefaultConversationRepository @Inject constructor( } } + // This assumes that recipient.isContactRecipient is true override fun setBlocked(recipient: Recipient, blocked: Boolean) { - recipientDb.setBlocked(recipient, blocked) + storage.setBlocked(listOf(recipient), blocked) } override fun deleteLocally(recipient: Recipient, message: MessageRecord) { @@ -139,7 +144,7 @@ class DefaultConversationRepository @Inject constructor( } override fun setApproved(recipient: Recipient, isApproved: Boolean) { - recipientDb.setApproved(recipient, isApproved) + storage.setRecipientApproved(recipient, isApproved) } override suspend fun deleteForEveryone( @@ -250,29 +255,33 @@ class DefaultConversationRepository @Inject constructor( override suspend fun deleteThread(threadId: Long): ResultOf { sessionJobDb.cancelPendingMessageSendJobs(threadId) - threadDb.deleteConversation(threadId) + storage.deleteConversation(threadId) return ResultOf.Success(Unit) } override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf { sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) - threadDb.deleteConversation(thread.threadId) + storage.deleteConversation(thread.threadId) return ResultOf.Success(Unit) } - override suspend fun clearAllMessageRequests(): ResultOf { + override suspend fun clearAllMessageRequests(block: Boolean): ResultOf { threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> while (reader.next != null) { deleteMessageRequest(reader.current) + val recipient = reader.current.recipient + if (block) { + setBlocked(recipient, true) + } } } return ResultOf.Success(Unit) } override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> - recipientDb.setApproved(recipient, true) + storage.setRecipientApproved(recipient, true) val message = MessageRequestResponse(true) - MessageSender.send(message, Destination.from(recipient.address)) + MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) .success { threadDb.setHasSent(threadId, true) continuation.resume(ResultOf.Success(Unit)) @@ -283,7 +292,7 @@ class DefaultConversationRepository @Inject constructor( override fun declineMessageRequest(threadId: Long) { sessionJobDb.cancelPendingMessageSendJobs(threadId) - threadDb.deleteConversation(threadId) + storage.deleteConversation(threadId) } override fun hasReceived(threadId: Long): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java index f42b55b5fe..85d8c8f436 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.service; import android.content.Context; import org.jetbrains.annotations.NotNull; +import org.session.libsession.database.StorageProtocol; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate; import org.session.libsession.messaging.messages.signal.IncomingMediaMessage; import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage; @@ -15,6 +17,7 @@ import org.session.libsignal.messages.SignalServiceGroup; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; @@ -35,12 +38,14 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM private final SmsDatabase smsDatabase; private final MmsDatabase mmsDatabase; + private final MmsSmsDatabase mmsSmsDatabase; private final Context context; public ExpiringMessageManager(Context context) { this.context = context.getApplicationContext(); this.smsDatabase = DatabaseComponent.get(context).smsDatabase(); this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase(); + this.mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); executor.execute(new LoadTask()); executor.execute(new ProcessTask()); @@ -79,12 +84,11 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM } if (message.getId() != null) { - DatabaseComponent.get(context).smsDatabase().deleteMessage(message.getId()); + smsDatabase.deleteMessage(message.getId()); } } private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) { - MmsDatabase database = DatabaseComponent.get(context).mmsDatabase(); String senderPublicKey = message.getSender(); Long sentTimestamp = message.getSentTimestamp(); @@ -106,6 +110,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM Address groupAddress = Address.fromSerialized(groupID); recipient = Recipient.from(context, groupAddress, false); } + Long threadId = MessagingModuleConfiguration.getShared().getStorage().getThreadId(recipient); + if (threadId == null) { + return; + } IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1, duration * 1000L, true, @@ -120,10 +128,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM Optional.absent(), Optional.absent()); //insert the timer update message - database.insertSecureDecryptedMessageInbox(mediaMessage, -1, true, true); + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, true); //set the timer to the conversation - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); + MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); } catch (IOException | MmsException ioe) { Log.e("Loki", "Failed to insert expiration update message."); @@ -131,28 +139,30 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM } private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) { - MmsDatabase database = DatabaseComponent.get(context).mmsDatabase(); Long sentTimestamp = message.getSentTimestamp(); String groupId = message.getGroupPublicKey(); int duration = message.getDuration(); - Address address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); - Recipient recipient = Recipient.from(context, address, false); + Address address; try { - OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); - database.insertSecureDecryptedMessageOutbox(timerUpdateMessage, -1, sentTimestamp, true); - if (groupId != null) { - // we need the group ID as recipient for setExpireMessages below - recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)), false); + address = Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)); + } else { + address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); } - //set the timer to the conversation - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); + Recipient recipient = Recipient.from(context, address, false); + StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); + message.setThreadID(storage.getOrCreateThreadIdFor(address)); + + OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); + mmsDatabase.insertSecureDecryptedMessageOutbox(timerUpdateMessage, message.getThreadID(), sentTimestamp, true); + //set the timer to the conversation + MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); } catch (MmsException | IOException ioe) { - Log.e("Loki", "Failed to insert expiration update message."); + Log.e("Loki", "Failed to insert expiration update message.", ioe); } } @@ -163,7 +173,7 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM @Override public void startAnyExpiration(long timestamp, @NotNull String author) { - MessageRecord messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageFor(timestamp, author); + MessageRecord messageRecord = mmsSmsDatabase.getMessageFor(timestamp, author); if (messageRecord != null) { boolean mms = messageRecord.isMms(); Recipient recipient = messageRecord.getRecipient(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index f9f5524efa..8b1975865d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -1,16 +1,23 @@ package org.thoughtcrime.securesms.sskenvironment import android.content.Context +import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.utilities.SSKEnvironment -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -class ProfileManager : SSKEnvironment.ProfileManagerProtocol { +class ProfileManager(private val context: Context, private val configFactory: ConfigFactory) : SSKEnvironment.ProfileManagerProtocol { override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { + if (recipient.isLocalNumber) return val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) @@ -20,10 +27,12 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol { contact.nickname = nickname contactDatabase.setContact(contact) } + contactUpdatedInternal(contact) } - override fun setName(context: Context, recipient: Recipient, name: String) { + override fun setName(context: Context, recipient: Recipient, name: String?) { // New API + if (recipient.isLocalNumber) return val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) @@ -37,40 +46,69 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol { val database = DatabaseComponent.get(context).recipientDatabase() database.setProfileName(recipient, name) recipient.notifyListeners() + contactUpdatedInternal(contact) } - override fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) { - val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address) - JobQueue.shared.add(job) + override fun setProfilePicture( + context: Context, + recipient: Recipient, + profilePictureURL: String?, + profileKey: ByteArray? + ) { + val hasPendingDownload = DatabaseComponent + .get(context) + .sessionJobDatabase() + .getAllJobs(RetrieveProfileAvatarJob.KEY).any { + (it.value as? RetrieveProfileAvatarJob)?.recipientAddress == recipient.address + } + val resolved = recipient.resolve() + DatabaseComponent.get(context).storage().setProfilePicture( + recipient = resolved, + newProfileKey = profileKey, + newProfilePicture = profilePictureURL + ) val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) if (contact == null) contact = Contact(sessionID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) - if (contact.profilePictureURL != profilePictureURL) { + if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) { + contact.profilePictureEncryptionKey = profileKey contact.profilePictureURL = profilePictureURL contactDatabase.setContact(contact) } - } - - override fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) { - // New API - val sessionID = recipient.address.serialize() - val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) - contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) - if (!contact.profilePictureEncryptionKey.contentEquals(profileKey)) { - contact.profilePictureEncryptionKey = profileKey - contactDatabase.setContact(contact) + contactUpdatedInternal(contact) + if (!hasPendingDownload) { + val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address) + JobQueue.shared.add(job) } - // Old API - val database = DatabaseComponent.get(context).recipientDatabase() - database.setProfileKey(recipient, profileKey) } override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) { val database = DatabaseComponent.get(context).recipientDatabase() database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode) } + + override fun contactUpdatedInternal(contact: Contact): String? { + val contactConfig = configFactory.contacts ?: return null + if (contact.sessionID == TextSecurePreferences.getLocalNumber(context)) return null + val sessionId = SessionId(contact.sessionID) + if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs + contactConfig.upsertContact(contact.sessionID) { + this.name = contact.name.orEmpty() + this.nickname = contact.nickname.orEmpty() + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + if (!url.isNullOrEmpty() && key != null && key.size == 32) { + this.profilePicture = UserPic(url, key) + } else if (url.isNullOrEmpty() && key == null) { + this.profilePicture = UserPic.DEFAULT + } + } + if (contactConfig.needsPush()) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + return contactConfig.get(contact.sessionID)?.hashCode()?.toString() + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt index 56c0a55dda..0ba63fc549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt @@ -1,12 +1,10 @@ package org.thoughtcrime.securesms.util import android.app.Notification -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.os.Build import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.app.NotificationCompat @@ -32,15 +30,7 @@ class CallNotificationBuilder { @JvmStatic fun areNotificationsEnabled(context: Context): Boolean { val notificationManager = NotificationManagerCompat.from(context) - return when { - !notificationManager.areNotificationsEnabled() -> false - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { - notificationManager.notificationChannels.firstOrNull { channel -> - channel.importance == NotificationManager.IMPORTANCE_NONE - } == null - } - else -> true - } + return notificationManager.areNotificationsEnabled() } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index fd462417d9..297014d86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -1,18 +1,66 @@ package org.thoughtcrime.securesms.util import android.content.Context +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.UserPic import nl.komponents.kovenant.Promise +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.ConfigurationSyncJob +import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.WindowDebouncer +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import java.util.Timer object ConfigurationMessageUtilities { + private val debouncer = WindowDebouncer(3000, Timer()) + + private fun scheduleConfigSync(userPublicKey: String) { + debouncer.publish { + // don't schedule job if we already have one + val storage = MessagingModuleConfiguration.shared.storage + val ourDestination = Destination.Contact(userPublicKey) + val currentStorageJob = storage.getConfigSyncJob(ourDestination) + if (currentStorageJob != null) { + (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) + return@publish + } + val newConfigSync = ConfigurationSyncJob(ourDestination) + JobQueue.shared.add(newConfigSync) + } + } + @JvmStatic fun syncConfigurationIfNeeded(context: Context) { + // add if check here to schedule new config job process and return early val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + scheduleConfigSync(userPublicKey) + return + } val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) val now = System.currentTimeMillis() if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return @@ -35,7 +83,16 @@ object ConfigurationMessageUtilities { } fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofSuccess(Unit) + // add if check here to schedule new config job process and return early + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + // schedule job if none exist + // don't schedule job if we already have one + scheduleConfigSync(userPublicKey) + return Promise.ofSuccess(Unit) + } val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() }.map { recipient -> @@ -50,9 +107,179 @@ object ConfigurationMessageUtilities { ) } val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) - val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey))) + val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true) TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) return promise } + private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes + + fun generateUserProfileConfigDump(): ByteArray? { + val storage = MessagingModuleConfiguration.shared.storage + val ownPublicKey = storage.getUserPublicKey() ?: return null + val config = ConfigurationMessage.getCurrent(listOf()) ?: return null + val secretKey = maybeUserSecretKey() ?: return null + val profile = UserProfile.newInstance(secretKey) + profile.setName(config.displayName) + val picUrl = config.profilePicture + val picKey = config.profileKey + if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) { + profile.setPic(UserPic(picUrl, picKey)) + } + val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey)) + profile.setNtsPriority( + if (ownThreadId != null) + if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + else ConfigBase.PRIORITY_HIDDEN + ) + val dump = profile.dump() + profile.free() + return dump + } + + fun generateContactConfigDump(): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val localUserKey = storage.getUserPublicKey() ?: return null + val contactsWithSettings = storage.getAllContacts().filter { recipient -> + recipient.sessionID != localUserKey && recipient.sessionID.startsWith(IdPrefix.STANDARD.value) + && storage.getThreadId(recipient.sessionID) != null + }.map { contact -> + val address = Address.fromSerialized(contact.sessionID) + val thread = storage.getThreadId(address) + val isPinned = if (thread != null) { + storage.isPinned(thread) + } else false + + Triple(contact, storage.getRecipientSettings(address)!!, isPinned) + } + val contactConfig = Contacts.newInstance(secretKey) + for ((contact, settings, isPinned) in contactsWithSettings) { + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + val userPic = if (url.isNullOrEmpty() || key?.isNotEmpty() != true) { + null + } else { + UserPic(url, key) + } + + val contactInfo = Contact( + id = contact.sessionID, + name = contact.name.orEmpty(), + nickname = contact.nickname.orEmpty(), + blocked = settings.isBlocked, + approved = settings.isApproved, + approvedMe = settings.hasApprovedMe(), + profilePicture = userPic ?: UserPic.DEFAULT, + priority = if (isPinned) 1 else 0, + expiryMode = if (settings.expireMessages == 0) ExpiryMode.NONE else ExpiryMode.AfterRead(settings.expireMessages.toLong()) + ) + contactConfig.set(contactInfo) + } + val dump = contactConfig.dump() + contactConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateConversationVolatileDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val convoConfig = ConversationVolatileConfig.newInstance(secretKey) + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.approvedConversationList.use { cursor -> + val reader = threadDb.readerFor(cursor) + var current = reader.next + while (current != null) { + val recipient = current.recipient + val contact = when { + recipient.isOpenGroupRecipient -> { + val openGroup = storage.getOpenGroup(current.threadId) ?: continue + val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue + convoConfig.getOrConstructCommunity(base, room, pubKey) + } + recipient.isClosedGroupRecipient -> { + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + convoConfig.getOrConstructLegacyGroup(groupPublicKey) + } + recipient.isContactRecipient -> { + if (recipient.isLocalNumber) null // this is handled by the user profile NTS data + else if (recipient.isOpenGroupInboxRecipient) null // specifically exclude + else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null + else convoConfig.getOrConstructOneToOne(recipient.address.serialize()) + } + else -> null + } + if (contact == null) { + current = reader.next + continue + } + contact.lastRead = current.lastSeen + contact.unread = false + convoConfig.set(contact) + current = reader.next + } + } + + val dump = convoConfig.dump() + convoConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateUserGroupDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val groupConfig = UserGroupsConfig.newInstance(secretKey) + val allOpenGroups = storage.getAllOpenGroups().values.mapNotNull { openGroup -> + val (baseUrl, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@mapNotNull null + val pubKeyHex = Hex.toStringCondensed(pubKey) + val baseInfo = BaseCommunityInfo(baseUrl, room, pubKeyHex) + val threadId = storage.getThreadId(openGroup) ?: return@mapNotNull null + val isPinned = storage.isPinned(threadId) + GroupInfo.CommunityGroupInfo(baseInfo, if (isPinned) 1 else 0) + } + + val allLgc = storage.getAllGroups(includeInactive = false).filter { + it.isClosedGroup && it.isActive && it.members.size > 1 + }.mapNotNull { group -> + val groupAddress = Address.fromSerialized(group.encodedId) + val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() + val recipient = storage.getRecipientSettings(groupAddress) ?: return@mapNotNull null + val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@mapNotNull null + val threadId = storage.getThreadId(group.encodedId) + val isPinned = threadId?.let { storage.isPinned(threadId) } ?: false + val admins = group.admins.map { it.serialize() to true }.toMap() + val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap() + GroupInfo.LegacyGroupInfo( + sessionId = groupPublicKey, + name = group.title, + members = admins + members, + priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = recipient.expireMessages.toLong(), + joinedAt = (group.formationTimestamp / 1000L) + ) + } + (allOpenGroups + allLgc).forEach { groupInfo -> + groupConfig.set(groupInfo) + } + val dump = groupConfig.dump() + groupConfig.free() + if (dump.isEmpty()) return null + return dump + } + + @JvmField + val DELETE_INACTIVE_GROUPS: String = """ + DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + """.trimIndent() + + @JvmField + val DELETE_INACTIVE_ONE_TO_ONES: String = """ + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; + """.trimIndent() + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 874440f5de..66c838cc1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -67,7 +67,8 @@ public class DateUtils extends android.text.format.DateUtils { } public static String getDisplayFormattedTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + // If the timestamp is invalid (ie. 0) then assume we're waiting on data and just use the 'Now' copy + if (timestamp == 0 || isWithin(timestamp, 1, TimeUnit.MINUTES)) { return c.getString(R.string.DateUtils_just_now); } else if (isToday(timestamp)) { return getFormattedDateTime(timestamp, getHourFormat(c), locale); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index 08b81e5cb7..c7d53c1fef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt @@ -55,16 +56,21 @@ object GlowViewUtilities { animation.start() } - fun animateShadowColorChange(view: GlowView, @ColorInt startColor: Int, @ColorInt endColor: Int) { + fun animateShadowColorChange( + view: GlowView, + @ColorInt startColor: Int, + @ColorInt endColor: Int, + duration: Long = 250 + ) { val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor) - animation.duration = 250 + animation.duration = duration + animation.interpolator = AccelerateDecelerateInterpolator() animation.addUpdateListener { animator -> val color = animator.animatedValue as Int view.sessionShadowColor = color } animation.start() } - } class PNModeView : LinearLayout, GlowView { @@ -223,3 +229,59 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView { } // endregion } + +class MessageBubbleView : androidx.constraintlayout.widget.ConstraintLayout, GlowView { + @ColorInt override var mainColor: Int = 0 + set(newValue) { field = newValue; paint.color = newValue } + @ColorInt override var sessionShadowColor: Int = 0 + set(newValue) { + field = newValue + shadowPaint.setShadowLayer(toPx(10, resources).toFloat(), 0.0f, 0.0f, newValue) + + if (numShadowRenders == 0) { + numShadowRenders = 1 + } + + invalidate() + } + var cornerRadius: Float = 0f + var numShadowRenders: Int = 0 + + private val paint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + private val shadowPaint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + // region Lifecycle + constructor(context: Context) : super(context) { } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { } + + init { + setWillNotDraw(false) + } + // endregion + + // region Updating + override fun onDraw(c: Canvas) { + val w = width.toFloat() + val h = height.toFloat() + + (0 until numShadowRenders).forEach { + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, shadowPaint) + } + + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint) + super.onDraw(c) + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index 10d507a538..06fda29306 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -7,8 +7,6 @@ import org.session.libsession.messaging.messages.signal.IncomingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI -import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient @@ -21,7 +19,6 @@ import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.GroupManager import java.security.SecureRandom -import java.util.* import kotlin.random.asKotlinRandom object MockDataGenerator { @@ -139,7 +136,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } @@ -235,8 +231,9 @@ object MockDataGenerator { // Add the group to the user's set of public keys to poll for and store the key pair val encryptionKeyPair = Curve.generateKeyPair() - storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey) + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) storage.setExpirationTimer(groupId, 0) + storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair) // Add the group created message if (userSessionId == adminUserId) { @@ -269,7 +266,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } @@ -395,7 +391,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt index 05b6fe86f8..c10e1b635d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt @@ -49,11 +49,11 @@ object SessionMetaProtocol { @JvmStatic fun shouldSendReadReceipt(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } @JvmStatic fun shouldSendTypingIndicator(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt new file mode 100644 index 0000000000..b15d82a33e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util + +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.database.model.ThreadRecord + +fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean { + val recipient = thread.recipient + if (recipient.isContactRecipient + && recipient.isOpenGroupInboxRecipient + && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { + return getOneToOne(recipient.address.serialize())?.unread == true + } else if (recipient.isClosedGroupRecipient) { + return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true + } else if (recipient.isOpenGroupRecipient) { + val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false + return getCommunity(openGroup.server, openGroup.room)?.unread == true + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 8345473490..dfd4ffe419 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -58,7 +58,7 @@ fun View.fadeIn(duration: Long = 150) { fun View.fadeOut(duration: Long = 150) { animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) visibility = View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index d96de5eedb..894de9de64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -303,7 +303,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va sdpMLineIndexes = sdpMLineIndexes, sdpMids = sdpMids, currentCallId - ), currentRecipient.address) + ), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) } } } @@ -437,7 +437,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va pendingIncomingIceUpdates.clear() val answerMessage = CallMessage.answer(answer.description, callId) Log.i("Loki", "Posting new answer") - MessageSender.sendNonDurably(answerMessage, recipient.address) + MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) } else { Promise.ofFail(Exception("Couldn't reconnect from current state")) } @@ -481,11 +481,11 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setLocalDescription(answer) val answerMessage = CallMessage.answer(answer.description, callId) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) - MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress)) + MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( answer.description, callId - ), recipient.address) + ), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) @@ -535,13 +535,13 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va Log.d("Loki", "Sending pre-offer") return MessageSender.sendNonDurably(CallMessage.preOffer( callId - ), recipient.address).bind { + ), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sending offer") MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId - ), recipient.address).success { + ), recipient.address, isSyncMessage = recipient.isLocalNumber).success { Log.d("Loki", "Sent offer") }.fail { Log.e("Loki", "Failed to send offer", it) @@ -555,8 +555,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val recipient = recipient ?: return val userAddress = storage.getUserPublicKey() ?: return stateProcessor.processEvent(Event.DeclineCall) { - MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress)) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress), isSyncMessage = true) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) } } @@ -575,7 +575,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) channel.send(buffer) } - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) } } @@ -726,7 +726,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va }) connection.setLocalDescription(offer) - MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber) } } diff --git a/app/src/main/res/color/prominent_button_color.xml b/app/src/main/res/color/prominent_button_color.xml index 8f2e692fda..39985565d1 100644 --- a/app/src/main/res/color/prominent_button_color.xml +++ b/app/src/main/res/color/prominent_button_color.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index d56b399fc3..5afde1e296 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -216,6 +216,19 @@ + + - + android:layout_height="@dimen/path_status_view_size" + android:layout_alignEnd="@+id/profileButton" + android:layout_alignBottom="@+id/profileButton" /> + android:layout_height="?actionBarSize" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:visibility="gone"> + + android:layout_height="wrap_content" + android:layout_centerVertical="true" /> + + + + + + android:clipChildren="false" + android:focusable="false"> + tools:listitem="@layout/view_global_search_result" /> diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index d328d19ab3..11155ccc20 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -8,6 +8,46 @@ android:layout_height="wrap_content" android:orientation="vertical"> + + + + + + - - + Failed to send Search GIFs? Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs. + Some of your devices are using outdated versions. Syncing may be unreliable until they are updated. + + There are no messages in %s. + You have no messages in Note to Self. + You have no messages from %s.\nSend a message to start the conversation! + + Unread Messages + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index edf8108d60..2d4ee8b7f7 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -642,4 +642,84 @@ @color/ocean_light_5 + + + + diff --git a/app/src/main/res/xml/preferences_app_protection.xml b/app/src/main/res/xml/preferences_app_protection.xml index 6119719930..12607ee769 100644 --- a/app/src/main/res/xml/preferences_app_protection.xml +++ b/app/src/main/res/xml/preferences_app_protection.xml @@ -12,7 +12,6 @@ android:title="@string/preferences_app_protection__screen_lock" android:summary="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint" /> - + convo is Conversation.OneToOne && convo.sessionId == definitelyRealId + } + assertEquals(1, numErased) + assertEquals(1, convos.sizeOneToOnes()) + } + + @Test + fun test_open_group_urls() { + val (base1, room1, pk1) = BaseCommunityInfo.parseFullUrl( + "https://example.com/" + + "someroom?public_key=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + )!! + + val (base2, room2, pk2) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.COM/" + + "someroom?public_key=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" + )!! + + val (base3, room3, pk3) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.COM/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base4, room4, pk4) = BaseCommunityInfo.parseFullUrl( + "http://example.com/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base5, room5, pk5) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.com:443/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base6, room6, pk6) = BaseCommunityInfo.parseFullUrl( + "HTTP://EXAMPLE.com:80/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base7, room7, pk7) = BaseCommunityInfo.parseFullUrl( + "http://example.com:80/r/" + + "someroom?public_key=ASNFZ4mrze8BI0VniavN7wEjRWeJq83vASNFZ4mrze8" + )!! + val (base8, room8, pk8) = BaseCommunityInfo.parseFullUrl( + "http://example.com:80/r/" + + "someroom?public_key=yrtwk3hjixg66yjdeiuauk6p7hy1gtm8tgih55abrpnsxnpm3zzo" + )!! + + assertEquals("https://example.com", base1) + assertEquals("http://example.com", base4) + assertEquals(base1, base2) + assertEquals(base1, base3) + assertNotEquals(base1, base4) + assertEquals(base1, base5) + assertEquals(base4, base6) + assertEquals(base4, base7) + assertEquals(base4, base8) + assertEquals("someroom", room1) + assertEquals("someroom", room2) + assertEquals("someroom", room3) + assertEquals("someroom", room4) + assertEquals("someroom", room5) + assertEquals("someroom", room6) + assertEquals("someroom", room7) + assertEquals("someroom", room8) + assertEquals(Hex.toStringCondensed(pk1), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk2), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk3), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk4), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk5), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk6), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk7), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk8), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + + } + + @Test + fun test_conversations() { + val convos = ConversationVolatileConfig.newInstance(keyPair.secretKey) + val definitelyRealId = "055000000000000000000000000000000000000000000000000000000000000000" + assertNull(convos.getOneToOne(definitelyRealId)) + assertTrue(convos.empty()) + assertEquals(0, convos.size()) + + val c = convos.getOrConstructOneToOne(definitelyRealId) + + assertEquals(definitelyRealId, c.sessionId) + assertEquals(0, c.lastRead) + + assertFalse(convos.needsPush()) + assertFalse(convos.needsDump()) + assertEquals(0, convos.push().seqNo) + + val nowMs = System.currentTimeMillis() + + c.lastRead = nowMs + + convos.set(c) + + assertNull(convos.getLegacyClosedGroup(definitelyRealId)) + assertNotNull(convos.getOneToOne(definitelyRealId)) + assertEquals(nowMs, convos.getOneToOne(definitelyRealId)?.lastRead) + + assertTrue(convos.needsPush()) + assertTrue(convos.needsDump()) + + val openGroupPubKey = Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + + val og = convos.getOrConstructCommunity("http://Example.ORG:5678", "SudokuRoom", openGroupPubKey) + val ogCommunity = og.baseCommunityInfo + + assertEquals("http://example.org:5678", ogCommunity.baseUrl) // Note: lower-case + assertEquals("sudokuroom", ogCommunity.room) // Note: lower-case + assertEquals(64, ogCommunity.pubKeyHex.length) + assertEquals("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ogCommunity.pubKeyHex) + + og.unread = true + + convos.set(og) + + val (_, seqNo) = convos.push() + + assertEquals(1, seqNo) + + convos.confirmPushed(seqNo, "fakehash1") + + assertTrue(convos.needsDump()) + assertFalse(convos.needsPush()) + + val convos2 = ConversationVolatileConfig.newInstance(keyPair.secretKey, convos.dump()) + assertFalse(convos.needsPush()) + assertFalse(convos.needsDump()) + assertEquals(1, convos.push().seqNo) + assertFalse(convos.needsDump()) + + val x1 = convos2.getOneToOne(definitelyRealId)!! + assertEquals(nowMs, x1.lastRead) + assertEquals(definitelyRealId, x1.sessionId) + assertEquals(false, x1.unread) + + val x2 = convos2.getCommunity("http://EXAMPLE.org:5678", "sudokuRoom")!! + val x2Info = x2.baseCommunityInfo + assertEquals("http://example.org:5678", x2Info.baseUrl) + assertEquals("sudokuroom", x2Info.room) + assertEquals(x2Info.pubKeyHex, Hex.toStringCondensed(openGroupPubKey)) + assertTrue(x2.unread) + + val anotherId = "051111111111111111111111111111111111111111111111111111111111111111" + val c2 = convos.getOrConstructOneToOne(anotherId) + c2.unread = true + convos2.set(c2) + + val c3 = convos.getOrConstructLegacyGroup( + "05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ) + c3.lastRead = nowMs - 50 + convos2.set(c3) + + assertTrue(convos2.needsPush()) + + val (toPush2, seqNo2) = convos2.push() + assertEquals(2, seqNo2) + + convos2.confirmPushed(seqNo2, "fakehash2") + convos.merge("fakehash2" to toPush2) + + assertFalse(convos.needsPush()) + assertEquals(seqNo2, convos.push().seqNo) + + val seen = mutableListOf() + for ((ind, conv) in listOf(convos, convos2).withIndex()) { + Log.e("Test","Testing seen from convo #$ind") + seen.clear() + assertEquals(4, conv.size()) + assertEquals(2, conv.sizeOneToOnes()) + assertEquals(1, conv.sizeCommunities()) + assertEquals(1, conv.sizeLegacyClosedGroups()) + assertFalse(conv.empty()) + val allConvos = conv.all() + for (convo in allConvos) { + when (convo) { + is Conversation.OneToOne -> seen.add("1-to-1: ${convo.sessionId}") + is Conversation.Community -> seen.add("og: ${convo.baseCommunityInfo.baseUrl}/r/${convo.baseCommunityInfo.room}") + is Conversation.LegacyGroup -> seen.add("cl: ${convo.groupId}") + } + } + + assertTrue(seen.contains("1-to-1: 051111111111111111111111111111111111111111111111111111111111111111")) + assertTrue(seen.contains("1-to-1: 055000000000000000000000000000000000000000000000000000000000000000")) + assertTrue(seen.contains("og: http://example.org:5678/r/sudokuroom")) + assertTrue(seen.contains("cl: 05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) + assertTrue(seen.size == 4) // for some reason iterative checks aren't working in test cases + } + + assertFalse(convos.needsPush()) + convos.eraseOneToOne("052000000000000000000000000000000000000000000000000000000000000000") + assertFalse(convos.needsPush()) + convos.eraseOneToOne("055000000000000000000000000000000000000000000000000000000000000000") + assertTrue(convos.needsPush()) + + assertEquals(1, convos.allOneToOnes().size) + assertEquals("051111111111111111111111111111111111111111111111111111111111111111", + convos.allOneToOnes().map(Conversation.OneToOne::sessionId).first() + ) + assertEquals(1, convos.allCommunities().size) + assertEquals("http://example.org:5678", + convos.allCommunities().map { it.baseCommunityInfo.baseUrl }.first() + ) + assertEquals(1, convos.allLegacyClosedGroups().size) + assertEquals("05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + convos.allLegacyClosedGroups().map(Conversation.LegacyGroup::groupId).first() + ) + } + +} \ No newline at end of file diff --git a/libsession-util/src/main/AndroidManifest.xml b/libsession-util/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..65483324a6 --- /dev/null +++ b/libsession-util/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/libsession-util/src/main/cpp/CMakeLists.txt b/libsession-util/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000000..d01523a24e --- /dev/null +++ b/libsession-util/src/main/cpp/CMakeLists.txt @@ -0,0 +1,66 @@ +# For more information about using CMake with Android Studio, read the +# documentation: https://d.android.com/studio/projects/add-native-code.html + +# Sets the minimum version of CMake required to build the native library. + +cmake_minimum_required(VERSION 3.18.1) + +# Declares and names the project. + +project("session_util") + +# Compiles in C++17 mode +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CMAKE_BUILD_TYPE Release) + +# Creates and names a library, sets it as either STATIC +# or SHARED, and provides the relative paths to its source code. +# You can define multiple libraries, and CMake builds them for you. +# Gradle automatically packages shared libraries with your APK. + +set(STATIC_BUNDLE ON) +add_subdirectory(../../../libsession-util libsession) + +set(SOURCES + user_profile.cpp + user_groups.cpp + config_base.cpp + contacts.cpp + conversation.cpp + util.cpp) + +add_library( # Sets the name of the library. + session_util + # Sets the library as a shared library. + SHARED + # Provides a relative path to your source file(s). + ${SOURCES}) + +# Searches for a specified prebuilt library and stores the path as a +# variable. Because CMake includes system libraries in the search path by +# default, you only need to specify the name of the public NDK library +# you want to add. CMake verifies that the library exists before +# completing its build. + +find_library( # Sets the name of the path variable. + log-lib + + # Specifies the name of the NDK library that + # you want CMake to locate. + log) + +# Specifies libraries CMake should link to your target library. You +# can link multiple libraries, such as libraries you define in this +# build script, prebuilt third-party libraries, or system libraries. + +target_link_libraries( # Specifies the target library. + session_util + PUBLIC + libsession::config + libsession::crypto + # Links the target library to the log library + # included in the NDK. + ${log-lib}) diff --git a/libsession-util/src/main/cpp/config_base.cpp b/libsession-util/src/main/cpp/config_base.cpp new file mode 100644 index 0000000000..eed3ec56af --- /dev/null +++ b/libsession-util/src/main/cpp/config_base.cpp @@ -0,0 +1,154 @@ +#include "config_base.h" +#include "util.h" + +extern "C" { +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_dirty(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto* configBase = ptrToConfigBase(env, thiz); + return configBase->is_dirty(); +} + +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_needsPush(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + return config->needs_push(); +} + +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_needsDump(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + return config->needs_dump(); +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_push(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + auto push_tuple = config->push(); + auto to_push_str = std::get<1>(push_tuple); + auto to_delete = std::get<2>(push_tuple); + + jbyteArray returnByteArray = util::bytes_from_ustring(env, to_push_str); + jlong seqNo = std::get<0>(push_tuple); + jclass returnObjectClass = env->FindClass("network/loki/messenger/libsession_util/util/ConfigPush"); + jclass stackClass = env->FindClass("java/util/Stack"); + jmethodID methodId = env->GetMethodID(returnObjectClass, "", "([BJLjava/util/List;)V"); + jmethodID stack_init = env->GetMethodID(stackClass, "", "()V"); + jobject our_stack = env->NewObject(stackClass, stack_init); + jmethodID push_stack = env->GetMethodID(stackClass, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto entry : to_delete) { + auto entry_jstring = env->NewStringUTF(entry.data()); + env->CallObjectMethod(our_stack, push_stack, entry_jstring); + } + jobject returnObject = env->NewObject(returnObjectClass, methodId, returnByteArray, seqNo, our_stack); + return returnObject; +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_free(JNIEnv *env, jobject thiz) { + auto config = ptrToConfigBase(env, thiz); + delete config; +} + +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_dump(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + auto dumped = config->dump(); + jbyteArray bytes = util::bytes_from_ustring(env, dumped); + return bytes; +} + +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_encryptionDomain(JNIEnv *env, + jobject thiz) { + auto conf = ptrToConfigBase(env, thiz); + return env->NewStringUTF(conf->encryption_domain()); +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_confirmPushed(JNIEnv *env, jobject thiz, + jlong seq_no, + jstring new_hash_jstring) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + auto new_hash = env->GetStringUTFChars(new_hash_jstring, nullptr); + conf->confirm_pushed(seq_no, new_hash); + env->ReleaseStringUTFChars(new_hash_jstring, new_hash); +} + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_merge___3Lkotlin_Pair_2(JNIEnv *env, jobject thiz, + jobjectArray to_merge) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + size_t number = env->GetArrayLength(to_merge); + std::vector> configs = {}; + for (int i = 0; i < number; i++) { + auto jElement = (jobject) env->GetObjectArrayElement(to_merge, i); + auto pair = extractHashAndData(env, jElement); + configs.push_back(pair); + } + return conf->merge(configs); +} + +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_merge__Lkotlin_Pair_2(JNIEnv *env, jobject thiz, + jobject to_merge) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + std::vector> configs = {extractHashAndData(env, to_merge)}; + return conf->merge(configs); +} + +#pragma clang diagnostic pop +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_configNamespace(JNIEnv *env, jobject thiz) { + auto conf = ptrToConfigBase(env, thiz); + return (std::int16_t) conf->storage_namespace(); +} +extern "C" +JNIEXPORT jclass JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_00024Companion_kindFor(JNIEnv *env, + jobject thiz, + jint config_namespace) { + auto user_class = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + auto contact_class = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + auto convo_volatile_class = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + auto group_list_class = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + switch (config_namespace) { + case (int)session::config::Namespace::UserProfile: + return user_class; + case (int)session::config::Namespace::Contacts: + return contact_class; + case (int)session::config::Namespace::ConvoInfoVolatile: + return convo_volatile_class; + case (int)session::config::Namespace::UserGroups: + return group_list_class; + default: + return nullptr; + } +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_currentHashes(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + auto vec = conf->current_hashes(); + for (std::string element: vec) { + env->CallObjectMethod(our_stack, push, env->NewStringUTF(element.data())); + } + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/config_base.h b/libsession-util/src/main/cpp/config_base.h new file mode 100644 index 0000000000..836fb04ef5 --- /dev/null +++ b/libsession-util/src/main/cpp/config_base.h @@ -0,0 +1,28 @@ +#ifndef SESSION_ANDROID_CONFIG_BASE_H +#define SESSION_ANDROID_CONFIG_BASE_H + +#include "session/config/base.hpp" +#include "util.h" +#include +#include + +inline session::config::ConfigBase* ptrToConfigBase(JNIEnv *env, jobject obj) { + jclass baseClass = env->FindClass("network/loki/messenger/libsession_util/ConfigBase"); + jfieldID pointerField = env->GetFieldID(baseClass, "pointer", "J"); + return (session::config::ConfigBase*) env->GetLongField(obj, pointerField); +} + +inline std::pair extractHashAndData(JNIEnv *env, jobject kotlin_pair) { + jclass pair = env->FindClass("kotlin/Pair"); + jfieldID first = env->GetFieldID(pair, "first", "Ljava/lang/Object;"); + jfieldID second = env->GetFieldID(pair, "second", "Ljava/lang/Object;"); + jstring hash_as_jstring = static_cast(env->GetObjectField(kotlin_pair, first)); + jbyteArray data_as_jbytes = static_cast(env->GetObjectField(kotlin_pair, second)); + auto hash_as_string = env->GetStringUTFChars(hash_as_jstring, nullptr); + auto data_as_ustring = util::ustring_from_bytes(env, data_as_jbytes); + auto ret_pair = std::pair{hash_as_string, data_as_ustring}; + env->ReleaseStringUTFChars(hash_as_jstring, hash_as_string); + return ret_pair; +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/cpp/contacts.cpp b/libsession-util/src/main/cpp/contacts.cpp new file mode 100644 index 0000000000..7d04904802 --- /dev/null +++ b/libsession-util/src/main/cpp/contacts.cpp @@ -0,0 +1,100 @@ +#include "contacts.h" +#include "util.h" + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_get(JNIEnv *env, jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + if (!contact) return nullptr; + jobject j_contact = serialize_contact(env, contact.value()); + return j_contact; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_getOrConstruct(JNIEnv *env, jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get_or_construct(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return serialize_contact(env, contact); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_set(JNIEnv *env, jobject thiz, + jobject contact) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto contact_info = deserialize_contact(env, contact, contacts); + contacts->set(contact_info); +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_erase(JNIEnv *env, jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + + bool result = contacts->erase(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return result; +} +extern "C" +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B(JNIEnv *env, + jobject thiz, + jbyteArray ed25519_secret_key) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto* contacts = new session::config::Contacts(secret_key, std::nullopt); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(contacts)); + + return newConfig; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + + auto* contacts = new session::config::Contacts(secret_key, initial); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(contacts)); + + return newConfig; +} +#pragma clang diagnostic pop +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_all(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (const auto& contact : *contacts) { + auto contact_obj = serialize_contact(env, contact); + env->CallObjectMethod(our_stack, push, contact_obj); + } + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/contacts.h b/libsession-util/src/main/cpp/contacts.h new file mode 100644 index 0000000000..c5496a68c8 --- /dev/null +++ b/libsession-util/src/main/cpp/contacts.h @@ -0,0 +1,109 @@ +#ifndef SESSION_ANDROID_CONTACTS_H +#define SESSION_ANDROID_CONTACTS_H + +#include +#include "session/config/contacts.hpp" +#include "util.h" + +inline session::config::Contacts *ptrToContacts(JNIEnv *env, jobject obj) { + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jfieldID pointerField = env->GetFieldID(contactsClass, "pointer", "J"); + return (session::config::Contacts *) env->GetLongField(obj, pointerField); +} + +inline jobject serialize_contact(JNIEnv *env, session::config::contact_info info) { + jclass contactClass = env->FindClass("network/loki/messenger/libsession_util/util/Contact"); + jmethodID constructor = env->GetMethodID(contactClass, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZLnetwork/loki/messenger/libsession_util/util/UserPic;ILnetwork/loki/messenger/libsession_util/util/ExpiryMode;)V"); + jstring id = env->NewStringUTF(info.session_id.data()); + jstring name = env->NewStringUTF(info.name.data()); + jstring nickname = env->NewStringUTF(info.nickname.data()); + jboolean approved, approvedMe, blocked; + approved = info.approved; + approvedMe = info.approved_me; + blocked = info.blocked; + auto created = info.created; + jobject profilePic = util::serialize_user_pic(env, info.profile_picture); + jobject returnObj = env->NewObject(contactClass, constructor, id, name, nickname, approved, + approvedMe, blocked, profilePic, info.priority, + util::serialize_expiry(env, info.exp_mode, info.exp_timer)); + return returnObj; +} + +inline session::config::contact_info +deserialize_contact(JNIEnv *env, jobject info, session::config::Contacts *conf) { + jclass contactClass = env->FindClass("network/loki/messenger/libsession_util/util/Contact"); + + jfieldID getId, getName, getNick, getApproved, getApprovedMe, getBlocked, getUserPic, getPriority, getExpiry, getHidden; + getId = env->GetFieldID(contactClass, "id", "Ljava/lang/String;"); + getName = env->GetFieldID(contactClass, "name", "Ljava/lang/String;"); + getNick = env->GetFieldID(contactClass, "nickname", "Ljava/lang/String;"); + getApproved = env->GetFieldID(contactClass, "approved", "Z"); + getApprovedMe = env->GetFieldID(contactClass, "approvedMe", "Z"); + getBlocked = env->GetFieldID(contactClass, "blocked", "Z"); + getUserPic = env->GetFieldID(contactClass, "profilePicture", + "Lnetwork/loki/messenger/libsession_util/util/UserPic;"); + getPriority = env->GetFieldID(contactClass, "priority", "I"); + getExpiry = env->GetFieldID(contactClass, "expiryMode", "Lnetwork/loki/messenger/libsession_util/util/ExpiryMode;"); + jstring name, nickname, session_id; + session_id = static_cast(env->GetObjectField(info, getId)); + name = static_cast(env->GetObjectField(info, getName)); + nickname = static_cast(env->GetObjectField(info, getNick)); + bool approved, approvedMe, blocked, hidden; + int priority = env->GetIntField(info, getPriority); + approved = env->GetBooleanField(info, getApproved); + approvedMe = env->GetBooleanField(info, getApprovedMe); + blocked = env->GetBooleanField(info, getBlocked); + jobject user_pic = env->GetObjectField(info, getUserPic); + jobject expiry_mode = env->GetObjectField(info, getExpiry); + + auto expiry_pair = util::deserialize_expiry(env, expiry_mode); + + std::string url; + session::ustring key; + + if (user_pic != nullptr) { + auto deserialized_pic = util::deserialize_user_pic(env, user_pic); + auto url_jstring = deserialized_pic.first; + auto url_bytes = env->GetStringUTFChars(url_jstring, nullptr); + url = std::string(url_bytes); + env->ReleaseStringUTFChars(url_jstring, url_bytes); + key = util::ustring_from_bytes(env, deserialized_pic.second); + } + + auto session_id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto name_bytes = name ? env->GetStringUTFChars(name, nullptr) : nullptr; + auto nickname_bytes = nickname ? env->GetStringUTFChars(nickname, nullptr) : nullptr; + + auto contact_info = conf->get_or_construct(session_id_bytes); + if (name_bytes) { + contact_info.name = name_bytes; + } + if (nickname_bytes) { + contact_info.nickname = nickname_bytes; + } + contact_info.approved = approved; + contact_info.approved_me = approvedMe; + contact_info.blocked = blocked; + if (!url.empty() && !key.empty()) { + contact_info.profile_picture = session::config::profile_pic(url, key); + } else { + contact_info.profile_picture = session::config::profile_pic(); + } + + env->ReleaseStringUTFChars(session_id, session_id_bytes); + if (name_bytes) { + env->ReleaseStringUTFChars(name, name_bytes); + } + if (nickname_bytes) { + env->ReleaseStringUTFChars(nickname, nickname_bytes); + } + + contact_info.priority = priority; + contact_info.exp_mode = expiry_pair.first; + contact_info.exp_timer = std::chrono::seconds(expiry_pair.second); + + return contact_info; +} + + +#endif //SESSION_ANDROID_CONTACTS_H diff --git a/libsession-util/src/main/cpp/conversation.cpp b/libsession-util/src/main/cpp/conversation.cpp new file mode 100644 index 0000000000..4f0f531dea --- /dev/null +++ b/libsession-util/src/main/cpp/conversation.cpp @@ -0,0 +1,352 @@ +#include +#include "conversation.h" + +#pragma clang diagnostic push + +extern "C" +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_00024Companion_newInstance___3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto* convo_info_volatile = new session::config::ConvoInfoVolatile(secret_key, std::nullopt); + + jclass convoClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jmethodID constructor = env->GetMethodID(convoClass, "", "(J)V"); + jobject newConfig = env->NewObject(convoClass, constructor, reinterpret_cast(convo_info_volatile)); + + return newConfig; +} +extern "C" +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + auto* convo_info_volatile = new session::config::ConvoInfoVolatile(secret_key, initial); + + jclass convoClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jmethodID constructor = env->GetMethodID(convoClass, "", "(J)V"); + jobject newConfig = env->NewObject(convoClass, constructor, reinterpret_cast(convo_info_volatile)); + + return newConfig; +} + + + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeOneToOnes(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conversations = ptrToConvoInfo(env, thiz); + return conversations->size_1to1(); +} + +#pragma clang diagnostic pop +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseAll(JNIEnv *env, + jobject thiz, + jobject predicate) { + std::lock_guard lock{util::util_mutex_}; + auto conversations = ptrToConvoInfo(env, thiz); + + jclass predicate_class = env->FindClass("kotlin/jvm/functions/Function1"); + jmethodID predicate_call = env->GetMethodID(predicate_class, "invoke", "(Ljava/lang/Object;)Ljava/lang/Object;"); + + jclass bool_class = env->FindClass("java/lang/Boolean"); + jmethodID bool_get = env->GetMethodID(bool_class, "booleanValue", "()Z"); + + int removed = 0; + auto to_erase = std::vector(); + + for (auto it = conversations->begin(); it != conversations->end(); ++it) { + auto result = env->CallObjectMethod(predicate, predicate_call, serialize_any(env, *it)); + bool bool_result = env->CallBooleanMethod(result, bool_get); + if (bool_result) { + to_erase.push_back(*it); + } + } + + for (auto & entry : to_erase) { + if (conversations->erase(entry)) { + removed++; + } + } + + return removed; +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_size(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConvoInfo(env, thiz); + return (jint)config->size(); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_empty(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConvoInfo(env, thiz); + return config->empty(); +} +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_set(JNIEnv *env, + jobject thiz, + jobject to_store) { + std::lock_guard lock{util::util_mutex_}; + + auto convos = ptrToConvoInfo(env, thiz); + + jclass one_to_one = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + jclass open_group = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + jclass legacy_closed_group = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + + jclass to_store_class = env->GetObjectClass(to_store); + if (env->IsSameObject(to_store_class, one_to_one)) { + // store as 1to1 + convos->set(deserialize_one_to_one(env, to_store, convos)); + } else if (env->IsSameObject(to_store_class,open_group)) { + // store as open_group + convos->set(deserialize_community(env, to_store, convos)); + } else if (env->IsSameObject(to_store_class,legacy_closed_group)) { + // store as legacy_closed_group + convos->set(deserialize_legacy_closed_group(env, to_store, convos)); + } +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOneToOne(JNIEnv *env, + jobject thiz, + jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto internal = convos->get_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + if (internal) { + return serialize_one_to_one(env, *internal); + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructOneToOne( + JNIEnv *env, jobject thiz, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto internal = convos->get_or_construct_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + return serialize_one_to_one(env, internal); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseOneToOne(JNIEnv *env, + jobject thiz, + jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto result = convos->erase_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + return result; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto open = convos->get_community(base_url_chars, room_chars); + if (open) { + auto serialized = serialize_open_group(env, *open); + return serialized; + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructCommunity__Ljava_lang_String_2Ljava_lang_String_2_3B( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jbyteArray pub_key) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto pub_key_ustring = util::ustring_from_bytes(env, pub_key); + auto open = convos->get_or_construct_community(base_url_chars, room_chars, pub_key_ustring); + auto serialized = serialize_open_group(env, open); + return serialized; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructCommunity__Ljava_lang_String_2Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto hex_chars = env->GetStringUTFChars(pub_key_hex, nullptr); + auto open = convos->get_or_construct_community(base_url_chars, room_chars, hex_chars); + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + env->ReleaseStringUTFChars(pub_key_hex, hex_chars); + auto serialized = serialize_open_group(env, open); + return serialized; +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseCommunity__Lnetwork_loki_messenger_libsession_1util_util_Conversation_Community_2(JNIEnv *env, + jobject thiz, + jobject open_group) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto deserialized = deserialize_community(env, open_group, convos); + return convos->erase(deserialized); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto result = convos->erase_community(base_url_chars, room_chars); + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + return result; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getLegacyClosedGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto lgc = convos->get_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + if (lgc) { + auto serialized = serialize_legacy_group(env, *lgc); + return serialized; + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructLegacyGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto lgc = convos->get_or_construct_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + return serialize_legacy_group(env, lgc); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseLegacyClosedGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto result = convos->erase_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + return result; +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_erase(JNIEnv *env, + jobject thiz, + jobject conversation) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto deserialized = deserialize_any(env, conversation, convos); + if (!deserialized.has_value()) return false; + return convos->erase(*deserialized); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeCommunities(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + return convos->size_communities(); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeLegacyClosedGroups( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + return convos->size_legacy_groups(); +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_all(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (const auto& convo : *convos) { + auto contact_obj = serialize_any(env, convo); + env->CallObjectMethod(our_stack, push, contact_obj); + } + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allOneToOnes(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto contact = convos->begin_1to1(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_one_to_one(env, *contact)); + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allCommunities(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto contact = convos->begin_communities(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_open_group(env, *contact)); + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allLegacyClosedGroups( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto contact = convos->begin_legacy_groups(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_legacy_group(env, *contact)); + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/conversation.h b/libsession-util/src/main/cpp/conversation.h new file mode 100644 index 0000000000..45e453a595 --- /dev/null +++ b/libsession-util/src/main/cpp/conversation.h @@ -0,0 +1,122 @@ +#ifndef SESSION_ANDROID_CONVERSATION_H +#define SESSION_ANDROID_CONVERSATION_H + +#include +#include "util.h" +#include "session/config/convo_info_volatile.hpp" + +inline session::config::ConvoInfoVolatile *ptrToConvoInfo(JNIEnv *env, jobject obj) { + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jfieldID pointerField = env->GetFieldID(contactsClass, "pointer", "J"); + return (session::config::ConvoInfoVolatile *) env->GetLongField(obj, pointerField); +} + +inline jobject serialize_one_to_one(JNIEnv *env, session::config::convo::one_to_one one_to_one) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Ljava/lang/String;JZ)V"); + auto session_id = env->NewStringUTF(one_to_one.session_id.data()); + auto last_read = one_to_one.last_read; + auto unread = one_to_one.unread; + jobject serialized = env->NewObject(clazz, constructor, session_id, last_read, unread); + return serialized; +} + +inline jobject serialize_open_group(JNIEnv *env, session::config::convo::community community) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto base_community = util::serialize_base_community(env, community); + jmethodID constructor = env->GetMethodID(clazz, "", + "(Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;JZ)V"); + auto last_read = community.last_read; + auto unread = community.unread; + jobject serialized = env->NewObject(clazz, constructor, base_community, last_read, unread); + return serialized; +} + +inline jobject serialize_legacy_group(JNIEnv *env, session::config::convo::legacy_group group) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Ljava/lang/String;JZ)V"); + auto group_id = env->NewStringUTF(group.id.data()); + auto last_read = group.last_read; + auto unread = group.unread; + jobject serialized = env->NewObject(clazz, constructor, group_id, last_read, unread); + return serialized; +} + +inline jobject serialize_any(JNIEnv *env, session::config::convo::any any) { + if (auto* dm = std::get_if(&any)) { + return serialize_one_to_one(env, *dm); + } else if (auto* og = std::get_if(&any)) { + return serialize_open_group(env, *og); + } else if (auto* lgc = std::get_if(&any)) { + return serialize_legacy_group(env, *lgc); + } + return nullptr; +} + +inline session::config::convo::one_to_one deserialize_one_to_one(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + auto id_getter = env->GetFieldID(clazz, "sessionId", "Ljava/lang/String;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + jstring id = static_cast(env->GetObjectField(info, id_getter)); + auto id_chars = env->GetStringUTFChars(id, nullptr); + std::string id_string = std::string{id_chars}; + auto deserialized = conf->get_or_construct_1to1(id_string); + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + env->ReleaseStringUTFChars(id, id_chars); + return deserialized; +} + +inline session::config::convo::community deserialize_community(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto base_community_getter = env->GetFieldID(clazz, "baseCommunityInfo", "Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + + auto base_community_info = env->GetObjectField(info, base_community_getter); + + auto base_community_deserialized = util::deserialize_base_community(env, base_community_info); + auto deserialized = conf->get_or_construct_community( + base_community_deserialized.base_url(), + base_community_deserialized.room(), + base_community_deserialized.pubkey() + ); + + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + + return deserialized; +} + +inline session::config::convo::legacy_group deserialize_legacy_closed_group(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + auto group_id_getter = env->GetFieldID(clazz, "groupId", "Ljava/lang/String;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + auto group_id = static_cast(env->GetObjectField(info, group_id_getter)); + auto group_id_bytes = env->GetStringUTFChars(group_id, nullptr); + auto group_id_string = std::string{group_id_bytes}; + auto deserialized = conf->get_or_construct_legacy_group(group_id_string); + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + env->ReleaseStringUTFChars(group_id, group_id_bytes); + return deserialized; +} + +inline std::optional deserialize_any(JNIEnv *env, jobject convo, session::config::ConvoInfoVolatile *conf) { + auto oto_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + auto og_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto lgc_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + auto object_class = env->GetObjectClass(convo); + if (env->IsSameObject(object_class, oto_class)) { + return session::config::convo::any{deserialize_one_to_one(env, convo, conf)}; + } else if (env->IsSameObject(object_class, og_class)) { + return session::config::convo::any{deserialize_community(env, convo, conf)}; + } else if (env->IsSameObject(object_class, lgc_class)) { + return session::config::convo::any{deserialize_legacy_closed_group(env, convo, conf)}; + } + return std::nullopt; +} + +#endif //SESSION_ANDROID_CONVERSATION_H \ No newline at end of file diff --git a/libsession-util/src/main/cpp/user_groups.cpp b/libsession-util/src/main/cpp/user_groups.cpp new file mode 100644 index 0000000000..4f2b0e6b85 --- /dev/null +++ b/libsession-util/src/main/cpp/user_groups.cpp @@ -0,0 +1,273 @@ +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +#include "user_groups.h" + + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_00024Companion_newInstance___3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + + auto* user_groups = new session::config::UserGroups(secret_key, std::nullopt); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(user_groups)); + + return newConfig; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + + auto* user_groups = new session::config::UserGroups(secret_key, initial); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jmethodID constructor = env->GetMethodID(contactsClass, "", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast(user_groups)); + + return newConfig; +} +#pragma clang diagnostic pop + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_util_GroupInfo_00024LegacyGroupInfo_00024Companion_NAME_1MAX_1LENGTH( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + return session::config::legacy_group_info::NAME_MAX_LENGTH; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getCommunityInfo(JNIEnv *env, + jobject thiz, + jstring base_url, + jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_url_bytes = env->GetStringUTFChars(base_url, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + + auto community = conf->get_community(base_url_bytes, room_bytes); + + jobject community_info = nullptr; + + if (community) { + community_info = serialize_community_info(env, *community); + } + env->ReleaseStringUTFChars(base_url, base_url_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + return community_info; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getLegacyGroupInfo(JNIEnv *env, + jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto legacy_group = conf->get_legacy_group(id_bytes); + jobject return_group = nullptr; + if (legacy_group) { + return_group = serialize_legacy_group_info(env, *legacy_group); + } + env->ReleaseStringUTFChars(session_id, id_bytes); + return return_group; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getOrConstructCommunityInfo( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_url_bytes = env->GetStringUTFChars(base_url, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + auto pub_hex_bytes = env->GetStringUTFChars(pub_key_hex, nullptr); + + auto group = conf->get_or_construct_community(base_url_bytes, room_bytes, pub_hex_bytes); + + env->ReleaseStringUTFChars(base_url, base_url_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + env->ReleaseStringUTFChars(pub_key_hex, pub_hex_bytes); + return serialize_community_info(env, group); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getOrConstructLegacyGroupInfo( + JNIEnv *env, jobject thiz, jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto group = conf->get_or_construct_legacy_group(id_bytes); + env->ReleaseStringUTFChars(session_id, id_bytes); + return serialize_legacy_group_info(env, group); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_set__Lnetwork_loki_messenger_libsession_1util_util_GroupInfo_2( + JNIEnv *env, jobject thiz, jobject group_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto community_info = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto legacy_info = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + auto object_class = env->GetObjectClass(group_info); + if (env->IsSameObject(community_info, object_class)) { + auto deserialized = deserialize_community_info(env, group_info, conf); + conf->set(deserialized); + } else if (env->IsSameObject(legacy_info, object_class)) { + auto deserialized = deserialize_legacy_group_info(env, group_info, conf); + conf->set(deserialized); + } +} + + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_erase__Lnetwork_loki_messenger_libsession_1util_util_GroupInfo_2( + JNIEnv *env, jobject thiz, jobject group_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto communityInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto legacyInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + if (env->GetObjectClass(group_info) == communityInfo) { + auto deserialized = deserialize_community_info(env, group_info, conf); + conf->erase(deserialized); + } else if (env->GetObjectClass(group_info) == legacyInfo) { + auto deserialized = deserialize_legacy_group_info(env, group_info, conf); + conf->erase(deserialized); + } +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_sizeCommunityInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + return conf->size_communities(); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_sizeLegacyGroupInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + return conf->size_legacy_groups(); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_size(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConvoInfo(env, thiz); + return conf->size(); +} + +inline jobject iterator_as_java_stack(JNIEnv *env, const session::config::UserGroups::iterator& begin, const session::config::UserGroups::iterator& end) { + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto it = begin; it != end;) { + // do something with it + auto item = *it; + jobject serialized = nullptr; + if (auto* lgc = std::get_if(&item)) { + serialized = serialize_legacy_group_info(env, *lgc); + } else if (auto* community = std::get_if(&item)) { + serialized = serialize_community_info(env, *community); + } + if (serialized != nullptr) { + env->CallObjectMethod(our_stack, push, serialized); + } + it++; + } + return our_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_all(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject all_stack = iterator_as_java_stack(env, conf->begin(), conf->end()); + return all_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_allCommunityInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject community_stack = iterator_as_java_stack(env, conf->begin_communities(), conf->end()); + return community_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_allLegacyGroupInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject legacy_stack = iterator_as_java_stack(env, conf->begin_legacy_groups(), conf->end()); + return legacy_stack; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseCommunity__Lnetwork_loki_messenger_libsession_1util_util_BaseCommunityInfo_2(JNIEnv *env, + jobject thiz, + jobject base_community_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_community = util::deserialize_base_community(env, base_community_info); + return conf->erase_community(base_community.base_url(),base_community.room()); +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring server, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto server_bytes = env->GetStringUTFChars(server, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + auto community = conf->get_community(server_bytes, room_bytes); + bool deleted = false; + if (community) { + deleted = conf->erase(*community); + } + env->ReleaseStringUTFChars(server, server_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + return deleted; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseLegacyGroup(JNIEnv *env, + jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto session_id_bytes = env->GetStringUTFChars(session_id, nullptr); + bool return_bool = conf->erase_legacy_group(session_id_bytes); + env->ReleaseStringUTFChars(session_id, session_id_bytes); + return return_bool; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/user_groups.h b/libsession-util/src/main/cpp/user_groups.h new file mode 100644 index 0000000000..c4754fe113 --- /dev/null +++ b/libsession-util/src/main/cpp/user_groups.h @@ -0,0 +1,139 @@ + +#ifndef SESSION_ANDROID_USER_GROUPS_H +#define SESSION_ANDROID_USER_GROUPS_H + +#include "jni.h" +#include "util.h" +#include "conversation.h" +#include "session/config/user_groups.hpp" + +inline session::config::UserGroups* ptrToUserGroups(JNIEnv *env, jobject obj) { + jclass configClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jfieldID pointerField = env->GetFieldID(configClass, "pointer", "J"); + return (session::config::UserGroups*) env->GetLongField(obj, pointerField); +} + +inline void deserialize_members_into(JNIEnv *env, jobject members_map, session::config::legacy_group_info& to_append_group) { + jclass map_class = env->FindClass("java/util/Map"); + jclass map_entry_class = env->FindClass("java/util/Map$Entry"); + jclass set_class = env->FindClass("java/util/Set"); + jclass iterator_class = env->FindClass("java/util/Iterator"); + jclass boxed_bool = env->FindClass("java/lang/Boolean"); + + jmethodID get_entry_set = env->GetMethodID(map_class, "entrySet", "()Ljava/util/Set;"); + jmethodID get_at = env->GetMethodID(set_class, "iterator", "()Ljava/util/Iterator;"); + jmethodID has_next = env->GetMethodID(iterator_class, "hasNext", "()Z"); + jmethodID next = env->GetMethodID(iterator_class, "next", "()Ljava/lang/Object;"); + jmethodID get_key = env->GetMethodID(map_entry_class, "getKey", "()Ljava/lang/Object;"); + jmethodID get_value = env->GetMethodID(map_entry_class, "getValue", "()Ljava/lang/Object;"); + jmethodID get_bool_value = env->GetMethodID(boxed_bool, "booleanValue", "()Z"); + + jobject entry_set = env->CallObjectMethod(members_map, get_entry_set); + jobject iterator = env->CallObjectMethod(entry_set, get_at); + + while (env->CallBooleanMethod(iterator, has_next)) { + jobject entry = env->CallObjectMethod(iterator, next); + jstring key = static_cast(env->CallObjectMethod(entry, get_key)); + jobject boxed = env->CallObjectMethod(entry, get_value); + bool is_admin = env->CallBooleanMethod(boxed, get_bool_value); + auto member_string = env->GetStringUTFChars(key, nullptr); + to_append_group.insert(member_string, is_admin); + env->ReleaseStringUTFChars(key, member_string); + } +} + +inline session::config::legacy_group_info deserialize_legacy_group_info(JNIEnv *env, jobject info, session::config::UserGroups* conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + auto id_field = env->GetFieldID(clazz, "sessionId", "Ljava/lang/String;"); + auto name_field = env->GetFieldID(clazz, "name", "Ljava/lang/String;"); + auto members_field = env->GetFieldID(clazz, "members", "Ljava/util/Map;"); + auto enc_pub_key_field = env->GetFieldID(clazz, "encPubKey", "[B"); + auto enc_sec_key_field = env->GetFieldID(clazz, "encSecKey", "[B"); + auto priority_field = env->GetFieldID(clazz, "priority", "I"); + auto disappearing_timer_field = env->GetFieldID(clazz, "disappearingTimer", "J"); + auto joined_at_field = env->GetFieldID(clazz, "joinedAt", "J"); + jstring id = static_cast(env->GetObjectField(info, id_field)); + jstring name = static_cast(env->GetObjectField(info, name_field)); + jobject members_map = env->GetObjectField(info, members_field); + jbyteArray enc_pub_key = static_cast(env->GetObjectField(info, enc_pub_key_field)); + jbyteArray enc_sec_key = static_cast(env->GetObjectField(info, enc_sec_key_field)); + int priority = env->GetIntField(info, priority_field); + long joined_at = env->GetLongField(info, joined_at_field); + + auto id_bytes = env->GetStringUTFChars(id, nullptr); + auto name_bytes = env->GetStringUTFChars(name, nullptr); + auto enc_pub_key_bytes = util::ustring_from_bytes(env, enc_pub_key); + auto enc_sec_key_bytes = util::ustring_from_bytes(env, enc_sec_key); + + auto info_deserialized = conf->get_or_construct_legacy_group(id_bytes); + + auto current_members = info_deserialized.members(); + for (auto member = current_members.begin(); member != current_members.end(); ++member) { + info_deserialized.erase(member->first); + } + deserialize_members_into(env, members_map, info_deserialized); + info_deserialized.name = name_bytes; + info_deserialized.enc_pubkey = enc_pub_key_bytes; + info_deserialized.enc_seckey = enc_sec_key_bytes; + info_deserialized.priority = priority; + info_deserialized.disappearing_timer = std::chrono::seconds(env->GetLongField(info, disappearing_timer_field)); + info_deserialized.joined_at = joined_at; + env->ReleaseStringUTFChars(id, id_bytes); + env->ReleaseStringUTFChars(name, name_bytes); + return info_deserialized; +} + +inline session::config::community_info deserialize_community_info(JNIEnv *env, jobject info, session::config::UserGroups* conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto base_info = env->GetFieldID(clazz, "community", "Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;"); + auto priority = env->GetFieldID(clazz, "priority", "I"); + jobject base_community_info = env->GetObjectField(info, base_info); + auto deserialized_base_info = util::deserialize_base_community(env, base_community_info); + int deserialized_priority = env->GetIntField(info, priority); + auto community_info = conf->get_or_construct_community(deserialized_base_info.base_url(), deserialized_base_info.room(), deserialized_base_info.pubkey_hex()); + community_info.priority = deserialized_priority; + return community_info; +} + +inline jobject serialize_members(JNIEnv *env, std::map members_map) { + jclass map_class = env->FindClass("java/util/HashMap"); + jclass boxed_bool = env->FindClass("java/lang/Boolean"); + jmethodID map_constructor = env->GetMethodID(map_class, "", "()V"); + jmethodID insert = env->GetMethodID(map_class, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + jmethodID new_bool = env->GetMethodID(boxed_bool, "", "(Z)V"); + + jobject new_map = env->NewObject(map_class, map_constructor); + for (auto it = members_map.begin(); it != members_map.end(); it++) { + auto session_id = env->NewStringUTF(it->first.data()); + bool is_admin = it->second; + auto jbool = env->NewObject(boxed_bool, new_bool, is_admin); + env->CallObjectMethod(new_map, insert, session_id, jbool); + } + return new_map; +} + +inline jobject serialize_legacy_group_info(JNIEnv *env, session::config::legacy_group_info info) { + jstring session_id = env->NewStringUTF(info.session_id.data()); + jstring name = env->NewStringUTF(info.name.data()); + jobject members = serialize_members(env, info.members()); + jbyteArray enc_pubkey = util::bytes_from_ustring(env, info.enc_pubkey); + jbyteArray enc_seckey = util::bytes_from_ustring(env, info.enc_seckey); + int priority = info.priority; + long joined_at = info.joined_at; + + jclass legacy_group_class = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + jmethodID constructor = env->GetMethodID(legacy_group_class, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;[B[BIJJ)V"); + jobject serialized = env->NewObject(legacy_group_class, constructor, session_id, name, members, enc_pubkey, enc_seckey, priority, (jlong) info.disappearing_timer.count(), joined_at); + return serialized; +} + +inline jobject serialize_community_info(JNIEnv *env, session::config::community_info info) { + auto priority = info.priority; + auto serialized_info = util::serialize_base_community(env, info); + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;I)V"); + jobject serialized = env->NewObject(clazz, constructor, serialized_info, priority); + return serialized; +} + +#endif //SESSION_ANDROID_USER_GROUPS_H diff --git a/libsession-util/src/main/cpp/user_profile.cpp b/libsession-util/src/main/cpp/user_profile.cpp new file mode 100644 index 0000000000..78b671ef0d --- /dev/null +++ b/libsession-util/src/main/cpp/user_profile.cpp @@ -0,0 +1,98 @@ +#include "user_profile.h" +#include "util.h" + +extern "C" { +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_00024Companion_newInstance___3B_3B( + JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); + auto* profile = new session::config::UserProfile(secret_key, std::optional(initial)); + + jclass userClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jmethodID constructor = env->GetMethodID(userClass, "", "(J)V"); + jobject newConfig = env->NewObject(userClass, constructor, reinterpret_cast(profile)); + + return newConfig; +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_00024Companion_newInstance___3B( + JNIEnv* env, + jobject, + jbyteArray secretKey) { + std::lock_guard lock{util::util_mutex_}; + auto* profile = new session::config::UserProfile(util::ustring_from_bytes(env, secretKey), std::nullopt); + + jclass userClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jmethodID constructor = env->GetMethodID(userClass, "", "(J)V"); + jobject newConfig = env->NewObject(userClass, constructor, reinterpret_cast(profile)); + + return newConfig; +} +#pragma clang diagnostic pop + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setName( + JNIEnv* env, + jobject thiz, + jstring newName) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto name_chars = env->GetStringUTFChars(newName, nullptr); + profile->set_name(name_chars); + env->ReleaseStringUTFChars(newName, name_chars); +} + +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getName(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto name = profile->get_name(); + if (name == std::nullopt) return nullptr; + jstring returnString = env->NewStringUTF(name->data()); + return returnString; +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getPic(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto pic = profile->get_profile_pic(); + + jobject returnObject = util::serialize_user_pic(env, pic); + + return returnObject; +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setPic(JNIEnv *env, jobject thiz, + jobject user_pic) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto pic = util::deserialize_user_pic(env, user_pic); + auto url = env->GetStringUTFChars(pic.first, nullptr); + auto key = util::ustring_from_bytes(env, pic.second); + profile->set_profile_pic(url, key); + env->ReleaseStringUTFChars(pic.first, url); +} + +} +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setNtsPriority(JNIEnv *env, jobject thiz, + jint priority) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + profile->set_nts_priority(priority); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getNtsPriority(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + return profile->get_nts_priority(); +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/user_profile.h b/libsession-util/src/main/cpp/user_profile.h new file mode 100644 index 0000000000..cb1b8d973b --- /dev/null +++ b/libsession-util/src/main/cpp/user_profile.h @@ -0,0 +1,14 @@ +#ifndef SESSION_ANDROID_USER_PROFILE_H +#define SESSION_ANDROID_USER_PROFILE_H + +#include "session/config/user_profile.hpp" +#include +#include + +inline session::config::UserProfile* ptrToProfile(JNIEnv* env, jobject obj) { + jclass configClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jfieldID pointerField = env->GetFieldID(configClass, "pointer", "J"); + return (session::config::UserProfile*) env->GetLongField(obj, pointerField); +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/cpp/util.cpp b/libsession-util/src/main/cpp/util.cpp new file mode 100644 index 0000000000..69469eac1e --- /dev/null +++ b/libsession-util/src/main/cpp/util.cpp @@ -0,0 +1,167 @@ +#include "util.h" +#include +#include + +namespace util { + + std::mutex util_mutex_ = std::mutex(); + + jbyteArray bytes_from_ustring(JNIEnv* env, session::ustring_view from_str) { + size_t length = from_str.length(); + auto jlength = (jsize)length; + jbyteArray new_array = env->NewByteArray(jlength); + env->SetByteArrayRegion(new_array, 0, jlength, (jbyte*)from_str.data()); + return new_array; + } + + session::ustring ustring_from_bytes(JNIEnv* env, jbyteArray byteArray) { + size_t len = env->GetArrayLength(byteArray); + auto bytes = env->GetByteArrayElements(byteArray, nullptr); + + session::ustring st{reinterpret_cast(bytes), len}; + env->ReleaseByteArrayElements(byteArray, bytes, 0); + return st; + } + + jobject serialize_user_pic(JNIEnv *env, session::config::profile_pic pic) { + jclass returnObjectClass = env->FindClass("network/loki/messenger/libsession_util/util/UserPic"); + jmethodID constructor = env->GetMethodID(returnObjectClass, "", "(Ljava/lang/String;[B)V"); + jstring url = env->NewStringUTF(pic.url.data()); + jbyteArray byteArray = util::bytes_from_ustring(env, pic.key); + return env->NewObject(returnObjectClass, constructor, url, byteArray); + } + + std::pair deserialize_user_pic(JNIEnv *env, jobject user_pic) { + jclass userPicClass = env->FindClass("network/loki/messenger/libsession_util/util/UserPic"); + jfieldID picField = env->GetFieldID(userPicClass, "url", "Ljava/lang/String;"); + jfieldID keyField = env->GetFieldID(userPicClass, "key", "[B"); + auto pic = (jstring)env->GetObjectField(user_pic, picField); + auto key = (jbyteArray)env->GetObjectField(user_pic, keyField); + return {pic, key}; + } + + jobject serialize_base_community(JNIEnv *env, const session::config::community& community) { + jclass base_community_clazz = env->FindClass("network/loki/messenger/libsession_util/util/BaseCommunityInfo"); + jmethodID base_community_constructor = env->GetMethodID(base_community_clazz, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); + auto base_url = env->NewStringUTF(community.base_url().data()); + auto room = env->NewStringUTF(community.room().data()); + auto pubkey_jstring = env->NewStringUTF(community.pubkey_hex().data()); + jobject ret = env->NewObject(base_community_clazz, base_community_constructor, base_url, room, pubkey_jstring); + return ret; + } + + session::config::community deserialize_base_community(JNIEnv *env, jobject base_community) { + jclass base_community_clazz = env->FindClass("network/loki/messenger/libsession_util/util/BaseCommunityInfo"); + jfieldID base_url_field = env->GetFieldID(base_community_clazz, "baseUrl", "Ljava/lang/String;"); + jfieldID room_field = env->GetFieldID(base_community_clazz, "room", "Ljava/lang/String;"); + jfieldID pubkey_hex_field = env->GetFieldID(base_community_clazz, "pubKeyHex", "Ljava/lang/String;"); + auto base_url = (jstring)env->GetObjectField(base_community,base_url_field); + auto room = (jstring)env->GetObjectField(base_community, room_field); + auto pub_key_hex = (jstring)env->GetObjectField(base_community, pubkey_hex_field); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto pub_key_hex_chars = env->GetStringUTFChars(pub_key_hex, nullptr); + + auto community = session::config::community(base_url_chars, room_chars, pub_key_hex_chars); + + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + env->ReleaseStringUTFChars(pub_key_hex, pub_key_hex_chars); + return community; + } + + jobject serialize_expiry(JNIEnv *env, const session::config::expiration_mode& mode, const std::chrono::seconds& time_seconds) { + jclass none = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$NONE"); + jfieldID none_instance = env->GetStaticFieldID(none, "INSTANCE", "Lnetwork/loki/messenger/libsession_util/util/ExpiryMode$NONE;"); + jclass after_send = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterSend"); + jmethodID send_init = env->GetMethodID(after_send, "", "(J)V"); + jclass after_read = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterRead"); + jmethodID read_init = env->GetMethodID(after_read, "", "(J)V"); + + if (mode == session::config::expiration_mode::none) { + return env->GetStaticObjectField(none, none_instance); + } else if (mode == session::config::expiration_mode::after_send) { + return env->NewObject(after_send, send_init, time_seconds.count()); + } else if (mode == session::config::expiration_mode::after_read) { + return env->NewObject(after_read, read_init, time_seconds.count()); + } + return nullptr; + } + + std::pair deserialize_expiry(JNIEnv *env, jobject expiry_mode) { + jclass parent = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode"); + jclass after_read = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterRead"); + jclass after_send = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterSend"); + jfieldID duration_seconds = env->GetFieldID(parent, "expirySeconds", "J"); + + jclass object_class = env->GetObjectClass(expiry_mode); + + if (object_class == after_read) { + return std::pair(session::config::expiration_mode::after_read, env->GetLongField(expiry_mode, duration_seconds)); + } else if (object_class == after_send) { + return std::pair(session::config::expiration_mode::after_send, env->GetLongField(expiry_mode, duration_seconds)); + } + return std::pair(session::config::expiration_mode::none, 0); + } + +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_Sodium_ed25519KeyPair(JNIEnv *env, jobject thiz, jbyteArray seed) { + std::array ed_pk; // NOLINT(cppcoreguidelines-pro-type-member-init) + std::array ed_sk; // NOLINT(cppcoreguidelines-pro-type-member-init) + auto seed_bytes = util::ustring_from_bytes(env, seed); + crypto_sign_ed25519_seed_keypair(ed_pk.data(), ed_sk.data(), seed_bytes.data()); + + jclass kp_class = env->FindClass("network/loki/messenger/libsession_util/util/KeyPair"); + jmethodID kp_constructor = env->GetMethodID(kp_class, "", "([B[B)V"); + + jbyteArray pk_jarray = util::bytes_from_ustring(env, session::ustring_view {ed_pk.data(), ed_pk.size()}); + jbyteArray sk_jarray = util::bytes_from_ustring(env, session::ustring_view {ed_sk.data(), ed_sk.size()}); + + jobject return_obj = env->NewObject(kp_class, kp_constructor, pk_jarray, sk_jarray); + return return_obj; +} +extern "C" +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_util_Sodium_ed25519PkToCurve25519(JNIEnv *env, + jobject thiz, + jbyteArray pk) { + auto ed_pk = util::ustring_from_bytes(env, pk); + std::array curve_pk; // NOLINT(cppcoreguidelines-pro-type-member-init) + int success = crypto_sign_ed25519_pk_to_curve25519(curve_pk.data(), ed_pk.data()); + if (success != 0) { + jclass exception = env->FindClass("java/lang/Exception"); + env->ThrowNew(exception, "Invalid crypto_sign_ed25519_pk_to_curve25519 operation"); + return nullptr; + } + jbyteArray curve_pk_jarray = util::bytes_from_ustring(env, session::ustring_view {curve_pk.data(), curve_pk.size()}); + return curve_pk_jarray; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_BaseCommunityInfo_00024Companion_parseFullUrl( + JNIEnv *env, jobject thiz, jstring full_url) { + auto bytes = env->GetStringUTFChars(full_url, nullptr); + auto [base, room, pk] = session::config::community::parse_full_url(bytes); + env->ReleaseStringUTFChars(full_url, bytes); + + jclass clazz = env->FindClass("kotlin/Triple"); + jmethodID constructor = env->GetMethodID(clazz, "", "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V"); + + auto base_j = env->NewStringUTF(base.data()); + auto room_j = env->NewStringUTF(room.data()); + auto pk_jbytes = util::bytes_from_ustring(env, pk); + + jobject triple = env->NewObject(clazz, constructor, base_j, room_j, pk_jbytes); + return triple; +} +extern "C" +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_util_BaseCommunityInfo_fullUrl(JNIEnv *env, + jobject thiz) { + auto deserialized = util::deserialize_base_community(env, thiz); + auto full_url = deserialized.full_url(); + return env->NewStringUTF(full_url.data()); +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/util.h b/libsession-util/src/main/cpp/util.h new file mode 100644 index 0000000000..9348e8bd7e --- /dev/null +++ b/libsession-util/src/main/cpp/util.h @@ -0,0 +1,24 @@ +#ifndef SESSION_ANDROID_UTIL_H +#define SESSION_ANDROID_UTIL_H + +#include +#include +#include +#include "session/types.hpp" +#include "session/config/profile_pic.hpp" +#include "session/config/user_groups.hpp" +#include "session/config/expiring.hpp" + +namespace util { + extern std::mutex util_mutex_; + jbyteArray bytes_from_ustring(JNIEnv* env, session::ustring_view from_str); + session::ustring ustring_from_bytes(JNIEnv* env, jbyteArray byteArray); + jobject serialize_user_pic(JNIEnv *env, session::config::profile_pic pic); + std::pair deserialize_user_pic(JNIEnv *env, jobject user_pic); + jobject serialize_base_community(JNIEnv *env, const session::config::community& base_community); + session::config::community deserialize_base_community(JNIEnv *env, jobject base_community); + jobject serialize_expiry(JNIEnv *env, const session::config::expiration_mode& mode, const std::chrono::seconds& time_seconds); + std::pair deserialize_expiry(JNIEnv *env, jobject expiry_mode); +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt new file mode 100644 index 0000000000..52fb541d7d --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -0,0 +1,200 @@ +package network.loki.messenger.libsession_util + +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.ConfigPush +import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log + + +sealed class ConfigBase(protected val /* yucky */ pointer: Long) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun kindFor(configNamespace: Int): Class + + fun ConfigBase.protoKindFor(): Kind = when (this) { + is UserProfile -> Kind.USER_PROFILE + is Contacts -> Kind.CONTACTS + is ConversationVolatileConfig -> Kind.CONVO_INFO_VOLATILE + is UserGroupsConfig -> Kind.GROUPS + } + + // TODO: time in future to activate (hardcoded to 1st jan 2024 for testing, change before release) + private const val ACTIVATE_TIME = 1690761600000 + + fun isNewConfigEnabled(forced: Boolean, currentTime: Long) = + forced || currentTime >= ACTIVATE_TIME + + const val PRIORITY_HIDDEN = -1 + const val PRIORITY_VISIBLE = 0 + const val PRIORITY_PINNED = 1 + + } + + external fun dirty(): Boolean + external fun needsPush(): Boolean + external fun needsDump(): Boolean + external fun push(): ConfigPush + external fun dump(): ByteArray + external fun encryptionDomain(): String + external fun confirmPushed(seqNo: Long, newHash: String) + external fun merge(toMerge: Array>): Int + external fun currentHashes(): List + + external fun configNamespace(): Int + + // Singular merge + external fun merge(toMerge: Pair): Int + + external fun free() + +} + +class Contacts(pointer: Long) : ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): Contacts + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): Contacts + } + + external fun get(sessionId: String): Contact? + external fun getOrConstruct(sessionId: String): Contact + external fun all(): List + external fun set(contact: Contact) + external fun erase(sessionId: String): Boolean + + /** + * Similar to [updateIfExists], but will create the underlying contact if it doesn't exist before passing to [updateFunction] + */ + fun upsertContact(sessionId: String, updateFunction: Contact.()->Unit = {}) { + if (sessionId.startsWith(IdPrefix.BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with a blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.UN_BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with an un-blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.BLINDEDV2.value)) { + Log.w("Loki", "Trying to create a contact with a blindedv2 ID prefix") + return + } + val contact = getOrConstruct(sessionId) + updateFunction(contact) + set(contact) + } + + /** + * Updates the contact by sessionId with a given [updateFunction], and applies to the underlying config. + * the [updateFunction] doesn't run if there is no contact + */ + fun updateIfExists(sessionId: String, updateFunction: Contact.()->Unit) { + if (sessionId.startsWith(IdPrefix.BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with a blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.UN_BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with an un-blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.BLINDEDV2.value)) { + Log.w("Loki", "Trying to create a contact with a blindedv2 ID prefix") + return + } + val contact = get(sessionId) ?: return + updateFunction(contact) + set(contact) + } +} + +class UserProfile(pointer: Long) : ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): UserProfile + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): UserProfile + } + + external fun setName(newName: String) + external fun getName(): String? + external fun getPic(): UserPic + external fun setPic(userPic: UserPic) + external fun setNtsPriority(priority: Int) + external fun getNtsPriority(): Int +} + +class ConversationVolatileConfig(pointer: Long): ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): ConversationVolatileConfig + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): ConversationVolatileConfig + } + + external fun getOneToOne(pubKeyHex: String): Conversation.OneToOne? + external fun getOrConstructOneToOne(pubKeyHex: String): Conversation.OneToOne + external fun eraseOneToOne(pubKeyHex: String): Boolean + + external fun getCommunity(baseUrl: String, room: String): Conversation.Community? + external fun getOrConstructCommunity(baseUrl: String, room: String, pubKeyHex: String): Conversation.Community + external fun getOrConstructCommunity(baseUrl: String, room: String, pubKey: ByteArray): Conversation.Community + external fun eraseCommunity(community: Conversation.Community): Boolean + external fun eraseCommunity(baseUrl: String, room: String): Boolean + + external fun getLegacyClosedGroup(groupId: String): Conversation.LegacyGroup? + external fun getOrConstructLegacyGroup(groupId: String): Conversation.LegacyGroup + external fun eraseLegacyClosedGroup(groupId: String): Boolean + external fun erase(conversation: Conversation): Boolean + + external fun set(toStore: Conversation) + + /** + * Erase all conversations that do not satisfy the `predicate`, similar to [MutableList.removeAll] + */ + external fun eraseAll(predicate: (Conversation) -> Boolean): Int + + external fun sizeOneToOnes(): Int + external fun sizeCommunities(): Int + external fun sizeLegacyClosedGroups(): Int + external fun size(): Int + + external fun empty(): Boolean + + external fun allOneToOnes(): List + external fun allCommunities(): List + external fun allLegacyClosedGroups(): List + external fun all(): List + +} + +class UserGroupsConfig(pointer: Long): ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): UserGroupsConfig + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): UserGroupsConfig + } + + external fun getCommunityInfo(baseUrl: String, room: String): GroupInfo.CommunityGroupInfo? + external fun getLegacyGroupInfo(sessionId: String): GroupInfo.LegacyGroupInfo? + external fun getOrConstructCommunityInfo(baseUrl: String, room: String, pubKeyHex: String): GroupInfo.CommunityGroupInfo + external fun getOrConstructLegacyGroupInfo(sessionId: String): GroupInfo.LegacyGroupInfo + external fun set(groupInfo: GroupInfo) + external fun erase(communityInfo: GroupInfo) + external fun eraseCommunity(baseCommunityInfo: BaseCommunityInfo): Boolean + external fun eraseCommunity(server: String, room: String): Boolean + external fun eraseLegacyGroup(sessionId: String): Boolean + external fun sizeCommunityInfo(): Int + external fun sizeLegacyGroupInfo(): Int + external fun size(): Int + external fun all(): List + external fun allCommunityInfo(): List + external fun allLegacyGroupInfo(): List +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt new file mode 100644 index 0000000000..a48d082a62 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt @@ -0,0 +1,11 @@ +package network.loki.messenger.libsession_util.util + +data class BaseCommunityInfo(val baseUrl: String, val room: String, val pubKeyHex: String) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun parseFullUrl(fullUrl: String): Triple? + } + external fun fullUrl(): String +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt new file mode 100644 index 0000000000..8cc22a6afe --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt @@ -0,0 +1,13 @@ +package network.loki.messenger.libsession_util.util + +data class Contact( + val id: String, + var name: String = "", + var nickname: String = "", + var approved: Boolean = false, + var approvedMe: Boolean = false, + var blocked: Boolean = false, + var profilePicture: UserPic = UserPic.DEFAULT, + var priority: Int = 0, + var expiryMode: ExpiryMode, +) \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt new file mode 100644 index 0000000000..97930e8b40 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt @@ -0,0 +1,25 @@ +package network.loki.messenger.libsession_util.util + +sealed class Conversation { + + abstract var lastRead: Long + abstract var unread: Boolean + + data class OneToOne( + val sessionId: String, + override var lastRead: Long, + override var unread: Boolean + ): Conversation() + + data class Community( + val baseCommunityInfo: BaseCommunityInfo, + override var lastRead: Long, + override var unread: Boolean + ) : Conversation() + + data class LegacyGroup( + val groupId: String, + override var lastRead: Long, + override var unread: Boolean + ): Conversation() +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt new file mode 100644 index 0000000000..58e98a4392 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt @@ -0,0 +1,7 @@ +package network.loki.messenger.libsession_util.util + +sealed class ExpiryMode(val expirySeconds: Long) { + object NONE: ExpiryMode(0) + class AfterSend(seconds: Long): ExpiryMode(seconds) + class AfterRead(seconds: Long): ExpiryMode(seconds) +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt new file mode 100644 index 0000000000..c8ace0a9a7 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt @@ -0,0 +1,53 @@ +package network.loki.messenger.libsession_util.util + +sealed class GroupInfo { + + data class CommunityGroupInfo(val community: BaseCommunityInfo, val priority: Int) : GroupInfo() + + data class LegacyGroupInfo( + val sessionId: String, + val name: String, + val members: Map, + val encPubKey: ByteArray, + val encSecKey: ByteArray, + val priority: Int, + val disappearingTimer: Long, + val joinedAt: Long + ): GroupInfo() { + companion object { + @Suppress("FunctionName") + external fun NAME_MAX_LENGTH(): Int + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LegacyGroupInfo + + if (sessionId != other.sessionId) return false + if (name != other.name) return false + if (members != other.members) return false + if (!encPubKey.contentEquals(other.encPubKey)) return false + if (!encSecKey.contentEquals(other.encSecKey)) return false + if (priority != other.priority) return false + if (disappearingTimer != other.disappearingTimer) return false + if (joinedAt != other.joinedAt) return false + + return true + } + + override fun hashCode(): Int { + var result = sessionId.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + members.hashCode() + result = 31 * result + encPubKey.contentHashCode() + result = 31 * result + encSecKey.contentHashCode() + result = 31 * result + priority + result = 31 * result + disappearingTimer.hashCode() + result = 31 * result + joinedAt.hashCode() + return result + } + } + +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt new file mode 100644 index 0000000000..6168bd2165 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt @@ -0,0 +1,9 @@ +package network.loki.messenger.libsession_util.util + +object Sodium { + init { + System.loadLibrary("session_util") + } + external fun ed25519KeyPair(seed: ByteArray): KeyPair + external fun ed25519PkToCurve25519(pk: ByteArray): ByteArray +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt new file mode 100644 index 0000000000..4222395b5d --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt @@ -0,0 +1,67 @@ +package network.loki.messenger.libsession_util.util + +data class ConfigPush(val config: ByteArray, val seqNo: Long, val obsoleteHashes: List) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ConfigPush + + if (!config.contentEquals(other.config)) return false + if (seqNo != other.seqNo) return false + if (obsoleteHashes != other.obsoleteHashes) return false + + return true + } + + override fun hashCode(): Int { + var result = config.contentHashCode() + result = 31 * result + seqNo.hashCode() + result = 31 * result + obsoleteHashes.hashCode() + return result + } + +} + +data class UserPic(val url: String, val key: ByteArray) { + companion object { + val DEFAULT = UserPic("", byteArrayOf()) + } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UserPic + + if (url != other.url) return false + if (!key.contentEquals(other.key)) return false + + return true + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + key.contentHashCode() + return result + } +} + +data class KeyPair(val pubKey: ByteArray, val secretKey: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as KeyPair + + if (!pubKey.contentEquals(other.pubKey)) return false + if (!secretKey.contentEquals(other.secretKey)) return false + + return true + } + + override fun hashCode(): Int { + var result = pubKey.contentHashCode() + result = 31 * result + secretKey.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt b/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt new file mode 100644 index 0000000000..3d156bfd4d --- /dev/null +++ b/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt @@ -0,0 +1,14 @@ +package network.loki.messenger.libsession_util + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + +} \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle index dd8959958e..045648e090 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -18,6 +18,7 @@ android { dependencies { implementation project(":libsignal") + implementation project(":libsession-util") implementation project(":liblazysodium") implementation "net.java.dev.jna:jna:5.8.0@aar" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" @@ -34,7 +35,6 @@ dependencies { implementation 'com.annimon:stream:1.1.8' implementation 'com.makeramen:roundedimageview:2.1.0' implementation 'com.esotericsoftware:kryo:5.1.1' - implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" @@ -46,10 +46,6 @@ dependencies { testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation "org.mockito:mockito-inline:4.0.0" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation 'org.powermock:powermock-api-mockito:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1' - testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" diff --git a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java index a448b3f7a7..f78089e25e 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java +++ b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java @@ -33,7 +33,7 @@ public class ResourceContactPhoto implements FallbackContactPhoto { Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(context.getResources().getDrawable(resourceId)); - foreground.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + foreground.setScaleType(ImageView.ScaleType.CENTER_CROP); if (inverted) { foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 9a88202470..4fff833835 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -2,12 +2,14 @@ package org.session.libsession.database import android.content.Context import android.net.Uri +import network.loki.messenger.libsession_util.ConfigBase import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageSendJob +import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -30,6 +32,7 @@ import org.session.libsession.utilities.recipients.Recipient.RecipientSettings import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact interface StorageProtocol { @@ -38,6 +41,9 @@ interface StorageProtocol { fun getUserX25519KeyPair(): ECKeyPair fun getUserProfile(): Profile fun setProfileAvatar(recipient: Recipient, profileAvatar: String?) + fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) + fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) + fun clearUserPic() // Signal fun getOrGenerateRegistrationID(): Int @@ -50,8 +56,10 @@ interface StorageProtocol { fun getMessageSendJob(messageSendJobID: String): MessageSendJob? fun getMessageReceiveJob(messageReceiveJobID: String): Job? fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): Job? + fun getConfigSyncJob(destination: Destination): Job? fun resumeMessageSendJobIfNeeded(messageSendJobID: String) fun isJobCanceled(job: Job): Boolean + fun cancelPendingMessageSendJobs(threadID: Long) // Authorization fun getAuthToken(room: String, server: String): String? @@ -67,7 +75,7 @@ interface StorageProtocol { fun updateOpenGroup(openGroup: OpenGroup) fun getOpenGroup(threadId: Long): OpenGroup? fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? - fun onOpenGroupAdded(server: String) + fun onOpenGroupAdded(server: String, room: String) fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) fun getOpenGroup(room: String, server: String): OpenGroup? @@ -119,6 +127,8 @@ interface StorageProtocol { // Closed Groups fun getGroup(groupID: String): GroupRecord? fun createGroup(groupID: String, title: String?, members: List
, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List
, formationTimestamp: Long) + fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) + fun updateGroupConfig(groupPublicKey: String) fun isGroupActive(groupPublicKey: String): Boolean fun setActive(groupID: String, value: Boolean) fun getZombieMembers(groupID: String): Set @@ -129,7 +139,7 @@ interface StorageProtocol { fun getAllActiveClosedGroupPublicKeys(): Set fun addClosedGroupPublicKey(groupPublicKey: String) fun removeClosedGroupPublicKey(groupPublicKey: String) - fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) + fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long) @@ -140,18 +150,20 @@ interface StorageProtocol { fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) - fun setExpirationTimer(groupID: String, duration: Int) + fun setExpirationTimer(address: String, duration: Int) // Groups - fun getAllGroups(): List + fun getAllGroups(includeInactive: Boolean): List // Settings fun setProfileSharing(address: Address, value: Boolean) + // Thread fun getOrCreateThreadIdFor(address: Address): Long - fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long + fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? fun getThreadId(publicKeyOrOpenGroupID: String): Long? + fun getThreadId(openGroup: OpenGroup): Long? fun getThreadId(address: Address): Long? fun getThreadId(recipient: Recipient): Long? fun getThreadIdForMms(mmsId: Long): Long @@ -159,7 +171,10 @@ interface StorageProtocol { fun trimThread(threadID: Long, threadLimit: Int) fun trimThreadBefore(threadID: Long, timestamp: Long) fun getMessageCount(threadID: Long): Long - fun deleteConversation(threadId: Long) + fun setPinned(threadID: Long, isPinned: Boolean) + fun isPinned(threadID: Long): Boolean + fun deleteConversation(threadID: Long) + fun setThreadDate(threadId: Long, newDate: Long) // Contacts fun getContactWithSessionID(sessionID: String): Contact? @@ -167,6 +182,7 @@ interface StorageProtocol { fun setContact(contact: Contact) fun getRecipientForThread(threadId: Long): Recipient? fun getRecipientSettings(address: Address): RecipientSettings? + fun addLibSessionContacts(contacts: List) fun addContacts(contacts: List) // Attachments @@ -177,13 +193,14 @@ interface StorageProtocol { /** * Returns the ID of the `TSIncomingMessage` that was constructed. */ - fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runIncrement: Boolean, runThreadUpdate: Boolean): Long? - fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) - fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int) + fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runThreadUpdate: Boolean): Long? + fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false) + fun getLastSeen(threadId: Long): Long fun updateThread(threadId: Long, unarchive: Boolean) fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) fun insertMessageRequestResponse(response: MessageRequestResponse) fun setRecipientApproved(recipient: Recipient, approved: Boolean) + fun getRecipientApproved(address: Address): Boolean fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) fun conversationHasOutgoing(userPublicKey: String): Boolean @@ -203,6 +220,12 @@ interface StorageProtocol { fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) fun deleteReactions(messageId: Long, mms: Boolean) - fun unblock(toUnblock: Iterable) + fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean = false) + fun setRecipientHash(recipient: Recipient, recipientHash: String?) fun blockedContacts(): List + + // Shared configs + fun notifyConfigUpdates(forConfigObject: ConfigBase) + fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean + fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean } diff --git a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 37c391dfd6..0437196772 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -4,12 +4,14 @@ import android.content.Context import com.goterl.lazysodium.utils.KeyPair import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.ConfigFactoryProtocol class MessagingModuleConfiguration( val context: Context, val storage: StorageProtocol, val messageDataProvider: MessageDataProvider, - val getUserED25519KeyPair: ()-> KeyPair? + val getUserED25519KeyPair: () -> KeyPair?, + val configFactory: ConfigFactoryProtocol ) { companion object { 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 ef1d7567b3..b9eaf8d50d 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 @@ -42,7 +42,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val threadID = storage.getThreadIdForMms(databaseMessageID) 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 cd4189a653..19b6555b50 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 @@ -16,7 +16,11 @@ import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource import org.session.libsession.utilities.UploadResult import org.session.libsignal.messages.SignalServiceAttachmentStream -import org.session.libsignal.streams.* +import org.session.libsignal.streams.AttachmentCipherOutputStream +import org.session.libsignal.streams.AttachmentCipherOutputStreamFactory +import org.session.libsignal.streams.DigestingRequestBody +import org.session.libsignal.streams.PaddingInputStream +import org.session.libsignal.streams.PlaintextOutputStreamFactory import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PushAttachmentData import org.session.libsignal.utilities.Util @@ -45,7 +49,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess private val MESSAGE_SEND_JOB_ID_KEY = "message_send_job_id" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { try { val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt index c5ec1bc74e..20442e5594 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -3,9 +3,7 @@ package org.session.libsession.messaging.jobs import okhttp3.HttpUrl 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.utilities.Data -import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsignal.utilities.Log @@ -29,7 +27,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { return "$server.$room" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { try { val openGroup = OpenGroupUrlParser.parseUrl(joinUrl) val storage = MessagingModuleConfiguration.shared.storage @@ -40,8 +38,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { return } storage.addOpenGroup(openGroup.joinUrl()) - Log.d(KEY, "onOpenGroupAdded(${openGroup.server})") - storage.onOpenGroupAdded(openGroup.server) + storage.onOpenGroupAdded(openGroup.server, openGroup.room) } catch (e: Exception) { Log.e("OpenGroupDispatcher", "Failed to add group because",e) delegate?.handleJobFailed(this, dispatcherName, e) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index fa07a7d9c0..3aea8a1e30 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -7,15 +7,26 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task -import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage +import org.session.libsession.messaging.messages.control.ConfigurationMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.ParsedMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.sending_receiving.* +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.messaging.sending_receiving.handle +import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions +import org.session.libsession.messaging.sending_receiving.handleUnsendRequest +import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities @@ -49,6 +60,9 @@ class BatchMessageReceiveJob( const val BATCH_DEFAULT_NUMBER = 512 + // used for processing messages that don't have a thread and shouldn't create one + const val NO_THREAD_MAPPING = -1L + // Keys used for database storage private val NUM_MESSAGES_KEY = "numMessages" private val DATA_KEY = "data" @@ -57,16 +71,27 @@ class BatchMessageReceiveJob( private val OPEN_GROUP_ID_KEY = "open_group_id" } - private fun getThreadId(message: Message, storage: StorageProtocol): Long { - val senderOrSync = when (message) { - is VisibleMessage -> message.syncTarget ?: message.sender!! - is ExpirationTimerUpdate -> message.syncTarget ?: message.sender!! - else -> message.sender!! + private fun shouldCreateThread(parsedMessage: ParsedMessage): Boolean { + val message = parsedMessage.message + if (message is VisibleMessage) return true + else { // message is control message otherwise + return when(message) { + is SharedConfigurationMessage -> false + is ClosedGroupControlMessage -> false // message.kind is ClosedGroupControlMessage.Kind.New && !message.isSenderSelf + is DataExtractionNotification -> false + is MessageRequestResponse -> false + is ExpirationTimerUpdate -> false + is ConfigurationMessage -> false + is TypingIndicator -> false + is UnsendRequest -> false + is ReadReceipt -> false + is CallMessage -> false // TODO: maybe + else -> false // shouldn't happen, or I guess would be Visible + } } - return storage.getOrCreateThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID) } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { executeAsync(dispatcherName).get() } @@ -77,15 +102,16 @@ class BatchMessageReceiveJob( val context = MessagingModuleConfiguration.shared.context val localUserPublicKey = storage.getUserPublicKey() val serverPublicKey = openGroupID?.let { storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) } + val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys() // parse and collect IDs messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { - val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey) + val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey, currentClosedGroups = currentClosedGroups) message.serverHash = serverHash - val threadID = getThreadId(message, storage) val parsedParams = ParsedMessage(messageParameters, message, proto) + val threadID = Message.getThreadId(message, openGroupID, storage, shouldCreateThread(parsedParams)) ?: NO_THREAD_MAPPING if (!threadMap.containsKey(threadID)) { threadMap[threadID] = mutableListOf(parsedParams) } else { @@ -115,77 +141,101 @@ class BatchMessageReceiveJob( // iterate over threads and persist them (persistence is the longest constant in the batch process operation) runBlocking(Dispatchers.IO) { - val deferredThreadMap = threadMap.entries.map { (threadId, messages) -> - async { - // The LinkedHashMap should preserve insertion order - val messageIds = linkedMapOf>() - messages.forEach { (parameters, message, proto) -> - try { - when (message) { - is VisibleMessage -> { - val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID, - runIncrement = false, - runThreadUpdate = false, - runProfileUpdate = true - ) - - if (messageId != null && message.reaction == null) { - val isUserBlindedSender = message.sender == serverPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId( - IdPrefix.BLINDED, it.publicKey.asBytes).hexString } - messageIds[messageId] = Pair( - (message.sender == localUserPublicKey || isUserBlindedSender), - message.hasMention + fun processMessages(threadId: Long, messages: List) = async { + // The LinkedHashMap should preserve insertion order + val messageIds = linkedMapOf>() + val myLastSeen = storage.getLastSeen(threadId) + var newLastSeen = if (myLastSeen == -1L) 0 else myLastSeen + messages.forEach { (parameters, message, proto) -> + try { + when (message) { + is VisibleMessage -> { + val isUserBlindedSender = + message.sender == serverPublicKey?.let { + SodiumUtilities.blindedKeyPair( + it, + MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! ) + }?.let { + SessionId( + IdPrefix.BLINDED, it.publicKey.asBytes + ).hexString } - parameters.openGroupMessageServerID?.let { - MessageReceiver.handleOpenGroupReactions(threadId, it, parameters.reactions) + val sentTimestamp = message.sentTimestamp!! + if (message.sender == localUserPublicKey || isUserBlindedSender) { + if (sentTimestamp > newLastSeen) { + newLastSeen = + sentTimestamp // use sent timestamp here since that is technically the last one we have } } + val messageId = MessageReceiver.handleVisibleMessage( + message, proto, openGroupID, threadId, + runThreadUpdate = false, + runProfileUpdate = true + ) - is UnsendRequest -> { - val deletedMessageId = MessageReceiver.handleUnsendRequest(message) - - // If we removed a message then ensure it isn't in the 'messageIds' - if (deletedMessageId != null) { - messageIds.remove(deletedMessageId) - } + if (messageId != null && message.reaction == null) { + messageIds[messageId] = Pair( + (message.sender == localUserPublicKey || isUserBlindedSender), + message.hasMention + ) } + parameters.openGroupMessageServerID?.let { + MessageReceiver.handleOpenGroupReactions( + threadId, + it, + parameters.reactions + ) + } + } - else -> MessageReceiver.handle(message, proto, openGroupID) - } - } catch (e: Exception) { - Log.e(TAG, "Couldn't process message (id: $id)", e) - if (e is MessageReceiver.Error && !e.isRetryable) { - Log.e(TAG, "Message failed permanently (id: $id)", e) - } else { - Log.e(TAG, "Message failed (id: $id)", e) - failures += parameters + is UnsendRequest -> { + val deletedMessageId = + MessageReceiver.handleUnsendRequest(message) + + // If we removed a message then ensure it isn't in the 'messageIds' + if (deletedMessageId != null) { + messageIds.remove(deletedMessageId) + } } + + else -> MessageReceiver.handle(message, proto, threadId, openGroupID) + } + } catch (e: Exception) { + Log.e(TAG, "Couldn't process message (id: $id)", e) + if (e is MessageReceiver.Error && !e.isRetryable) { + Log.e(TAG, "Message failed permanently (id: $id)", e) + } else { + Log.e(TAG, "Message failed (id: $id)", e) + failures += parameters } } - // increment unreads, notify, and update thread - val unreadFromMine = messageIds.map { it.value.first }.indexOfLast { it } - var trueUnreadCount = messageIds.filter { !it.value.first }.size - var trueUnreadMentionCount = messageIds.filter { !it.value.first && it.value.second }.size - if (unreadFromMine >= 0) { - storage.markConversationAsRead(threadId, false) - - val trueUnreadIds = messageIds.keys.toList().subList(unreadFromMine + 1, messageIds.keys.count()) - trueUnreadCount = trueUnreadIds.size - trueUnreadMentionCount = messageIds - .filter { trueUnreadIds.contains(it.key) && !it.value.first && it.value.second } - .size - } - if (trueUnreadCount > 0) { - storage.incrementUnread(threadId, trueUnreadCount, trueUnreadMentionCount) - } - storage.updateThread(threadId, true) - SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) } + // increment unreads, notify, and update thread + // last seen will be the current last seen if not changed (re-computes the read counts for thread record) + // might have been updated from a different thread at this point + val currentLastSeen = storage.getLastSeen(threadId).let { if (it == -1L) 0 else it } + if (currentLastSeen > newLastSeen) { + newLastSeen = currentLastSeen + } + if (newLastSeen > 0 || currentLastSeen == 0L) { + storage.markConversationAsRead(threadId, newLastSeen, force = true) + } + storage.updateThread(threadId, true) + SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) + } + + val withoutDefault = threadMap.entries.filter { it.key != NO_THREAD_MAPPING } + val noThreadMessages = threadMap[NO_THREAD_MAPPING] ?: listOf() + val deferredThreadMap = withoutDefault.map { (threadId, messages) -> + processMessages(threadId, messages) } // await all thread processing deferredThreadMap.awaitAll() + if (noThreadMessages.isNotEmpty()) { + processMessages(NO_THREAD_MAPPING, noThreadMessages).await() + } } if (failures.isEmpty()) { handleSuccess(dispatcherName) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt new file mode 100644 index 0000000000..ec8de44163 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -0,0 +1,206 @@ +package org.session.libsession.messaging.jobs + +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor +import nl.komponents.kovenant.functional.bind +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.snode.RawResponse +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import java.util.concurrent.atomic.AtomicBoolean + +// only contact (self) and closed group destinations will be supported +data class ConfigurationSyncJob(val destination: Destination): Job { + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 10 + + val shouldRunAgain = AtomicBoolean(false) + + override suspend fun execute(dispatcherName: String) { + val storage = MessagingModuleConfiguration.shared.storage + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(MessagingModuleConfiguration.shared.context) + val currentTime = SnodeAPI.nowWithOffset + val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() + val userPublicKey = storage.getUserPublicKey() + val delegate = delegate + if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature + // if we haven't enabled the new configs don't run + || !ConfigBase.isNewConfigEnabled(forcedConfig, currentTime) + // if we don't have a user ed key pair for signing updates + || userEdKeyPair == null + // this will be useful to not handle null delegate cases + || delegate == null + // check our local identity key exists + || userPublicKey.isNullOrEmpty() + // don't allow pushing configs for non-local user + || (destination is Destination.Contact && destination.publicKey != userPublicKey) + ) { + Log.w(TAG, "No need to run config sync job, TODO") + return delegate?.handleJobSucceeded(this, dispatcherName) ?: Unit + } + + // configFactory singleton instance will come in handy for modifying hashes and fetching configs for namespace etc + val configFactory = MessagingModuleConfiguration.shared.configFactory + + // get latest states, filter out configs that don't need push + val configsRequiringPush = configFactory.getUserConfigs().filter { config -> config.needsPush() } + + // don't run anything if we don't need to push anything + if (configsRequiringPush.isEmpty()) return delegate.handleJobSucceeded(this, dispatcherName) + + // need to get the current hashes before we call `push()` + val toDeleteHashes = mutableListOf() + + // allow null results here so the list index matches configsRequiringPush + val sentTimestamp: Long = SnodeAPI.nowWithOffset + val batchObjects: List?> = configsRequiringPush.map { config -> + val (data, seqNo, obsoleteHashes) = config.push() + toDeleteHashes += obsoleteHashes + SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config + }.map { (message, config) -> + // return a list of batch request objects + val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true) + val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( + destination.destinationPublicKey(), + config.configNamespace(), + snodeMessage + ) ?: return@map null // this entry will be null otherwise + message to authenticated // to keep track of seqNo for calling confirmPushed later + } + + val toDeleteRequest = toDeleteHashes.let { toDeleteFromAllNamespaces -> + if (toDeleteFromAllNamespaces.isEmpty()) null + else SnodeAPI.buildAuthenticatedDeleteBatchInfo(destination.destinationPublicKey(), toDeleteFromAllNamespaces) + } + + if (batchObjects.any { it == null }) { + // stop running here, something like a signing error occurred + return delegate.handleJobFailedPermanently(this, dispatcherName, NullPointerException("One or more requests had a null batch request info")) + } + + val allRequests = mutableListOf() + allRequests += batchObjects.requireNoNulls().map { (_, request) -> request } + // add in the deletion if we have any hashes + if (toDeleteRequest != null) { + allRequests += toDeleteRequest + Log.d(TAG, "Including delete request for current hashes") + } + + val batchResponse = SnodeAPI.getSingleTargetSnode(destination.destinationPublicKey()).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + destination.destinationPublicKey(), + allRequests, + sequence = true + ) + } + + try { + val rawResponses = batchResponse.get() + @Suppress("UNCHECKED_CAST") + val responseList = (rawResponses["results"] as List) + // we are always adding in deletions at the end + val deletionResponse = if (toDeleteRequest != null && responseList.isNotEmpty()) responseList.last() else null + val deletedHashes = deletionResponse?.let { + @Suppress("UNCHECKED_CAST") + // get the sub-request body + (deletionResponse["body"] as? RawResponse)?.let { body -> + // get the swarm dict + body["swarm"] as? RawResponse + }?.mapValues { (_, swarmDict) -> + // get the deleted values from dict + ((swarmDict as? RawResponse)?.get("deleted") as? List)?.toSet() ?: emptySet() + }?.values?.reduce { acc, strings -> + // create an intersection of all deleted hashes (common between all swarm nodes) + acc intersect strings + } + } ?: emptySet() + + // at this point responseList index should line up with configsRequiringPush index + configsRequiringPush.forEachIndexed { index, config -> + val (toPushMessage, _) = batchObjects[index]!! + val response = responseList[index] + val responseBody = response["body"] as? RawResponse + val insertHash = responseBody?.get("hash") as? String ?: run { + Log.w(TAG, "No hash returned for the configuration in namespace ${config.configNamespace()}") + return@forEachIndexed + } + Log.d(TAG, "Hash ${insertHash.take(4)} returned from store request for new config") + + // confirm pushed seqno + val thisSeqNo = toPushMessage.seqNo + config.confirmPushed(thisSeqNo, insertHash) + Log.d(TAG, "Successfully removed the deleted hashes from ${config.javaClass.simpleName}") + // dump and write config after successful + if (config.needsDump()) { // usually this will be true? + configFactory.persist(config, toPushMessage.sentTimestamp ?: sentTimestamp) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error performing batch request", e) + return delegate.handleJobFailed(this, dispatcherName, e) + } + delegate.handleJobSucceeded(this, dispatcherName) + if (shouldRunAgain.get() && storage.getConfigSyncJob(destination) == null) { + // reschedule if something has updated since we started this job + JobQueue.shared.add(ConfigurationSyncJob(destination)) + } + } + + fun Destination.destinationPublicKey(): String = when (this) { + is Destination.Contact -> publicKey + is Destination.ClosedGroup -> groupPublicKey + else -> throw NullPointerException("Not public key for this destination") + } + + override fun serialize(): Data { + val (type, address) = when (destination) { + is Destination.Contact -> CONTACT_TYPE to destination.publicKey + is Destination.ClosedGroup -> GROUP_TYPE to destination.groupPublicKey + else -> return Data.EMPTY + } + return Data.Builder() + .putInt(DESTINATION_TYPE_KEY, type) + .putString(DESTINATION_ADDRESS_KEY, address) + .build() + } + + override fun getFactoryKey(): String = KEY + + companion object { + const val TAG = "ConfigSyncJob" + const val KEY = "ConfigSyncJob" + + // Keys used for DB storage + const val DESTINATION_ADDRESS_KEY = "destinationAddress" + const val DESTINATION_TYPE_KEY = "destinationType" + + // type mappings + const val CONTACT_TYPE = 1 + const val GROUP_TYPE = 2 + + } + + class Factory: Job.Factory { + override fun create(data: Data): ConfigurationSyncJob? { + if (!data.hasInt(DESTINATION_TYPE_KEY) || !data.hasString(DESTINATION_ADDRESS_KEY)) return null + + val address = data.getString(DESTINATION_ADDRESS_KEY) + val destination = when (data.getInt(DESTINATION_TYPE_KEY)) { + CONTACT_TYPE -> Destination.Contact(address) + GROUP_TYPE -> Destination.ClosedGroup(address) + else -> return null + } + + return ConfigurationSyncJob(destination) + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt index 07fd6254da..f0831b8bb6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt @@ -13,14 +13,18 @@ class GroupAvatarDownloadJob(val server: String, val room: String, val imageId: override var failureCount: Int = 0 override val maxFailureCount: Int = 10 - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { if (imageId == null) { delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob now requires imageId")) return } - val storage = MessagingModuleConfiguration.shared.storage - val storedImageId = storage.getOpenGroup(room, server)?.imageId + val openGroup = storage.getOpenGroup(room, server) + if (openGroup == null || storage.getThreadId(openGroup) == null) { + delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob openGroup is null")) + return + } + val storedImageId = openGroup.imageId if (storedImageId == null || storedImageId != imageId) { delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob imageId does not match the OpenGroup")) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt index 74e324f0ea..7f3bf9b173 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt @@ -17,7 +17,7 @@ interface Job { internal const val MAX_BUFFER_SIZE = 1_000_000 // bytes } - fun execute(dispatcherName: String) + suspend fun execute(dispatcherName: String) fun serialize(): Data 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 03b9546c4c..b437808f98 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 @@ -94,7 +94,7 @@ class JobQueue : JobDelegate { } } - private fun Job.process(dispatcherName: String) { + private suspend fun Job.process(dispatcherName: String) { Log.d(dispatcherName,"processJob: ${javaClass.simpleName} (id: $id)") delegate = this@JobQueue @@ -122,7 +122,7 @@ class JobQueue : JobDelegate { while (isActive) { when (val job = queue.receive()) { - is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> { + is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob, is ConfigurationSyncJob -> { txQueue.send(job) } is RetrieveProfileAvatarJob, @@ -226,6 +226,7 @@ class JobQueue : JobDelegate { BackgroundGroupAddJob.KEY, OpenGroupDeleteJob.KEY, RetrieveProfileAvatarJob.KEY, + ConfigurationSyncJob.KEY, ) allJobTypes.forEach { type -> resumePendingJobs(type) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt index 2ba33b5632..1ac482d5b4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt @@ -3,6 +3,7 @@ package org.session.libsession.messaging.jobs import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.utilities.Data @@ -25,20 +26,22 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val private val OPEN_GROUP_ID_KEY = "open_group_id" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { executeAsync(dispatcherName).get() } fun executeAsync(dispatcherName: String): Promise { val deferred = deferred() try { - val isRetry: Boolean = failureCount != 0 + val storage = MessagingModuleConfiguration.shared.storage val serverPublicKey = openGroupID?.let { - MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) + storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) } - val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey) + val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys() + val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey, currentClosedGroups = currentClosedGroups) + val threadId = Message.getThreadId(message, this.openGroupID, storage, false) message.serverHash = serverHash - MessageReceiver.handle(message, proto, this.openGroupID) + MessageReceiver.handle(message, proto, threadId ?: -1, this.openGroupID) this.handleSuccess(dispatcherName) deferred.resolve(Unit) } catch (e: Exception) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 524338592c..2a152d0a01 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -10,7 +10,6 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.OnionRequestAPI import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log @@ -33,7 +32,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { private val DESTINATION_KEY = "destination" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val message = message as? VisibleMessage val storage = MessagingModuleConfiguration.shared.storage @@ -65,7 +64,8 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { return } // Wait for all attachments to upload before continuing } - val promise = MessageSender.send(this.message, this.destination).success { + val isSync = destination is Destination.Contact && destination.publicKey == sender + val promise = MessageSender.send(this.message, this.destination, isSync).success { this.handleSuccess(dispatcherName) }.fail { exception -> var logStacktrace = true diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 25fb2194c8..be58544970 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -8,15 +8,13 @@ import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE - import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.Version - -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.retryIfNeeded class NotifyPNServerJob(val message: SnodeMessage) : Job { @@ -32,7 +30,7 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { private val MESSAGE_KEY = "message" } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val server = PushNotificationAPI.server val parameters = mapOf( "data" to message.data, "send_to" to message.recipient ) val url = "${server}/notify" diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt index 4c76f87633..333c87ba78 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -19,7 +19,7 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th override var failureCount: Int = 0 override val maxFailureCount: Int = 1 - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val numberToDelete = messageServerIds.size Log.d(TAG, "Deleting $numberToDelete messages") diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt index 5c617fbdb0..9ca2534f66 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt @@ -1,16 +1,14 @@ package org.session.libsession.messaging.jobs -import android.text.TextUtils import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.Data +import org.session.libsession.utilities.Address import org.session.libsession.utilities.DownloadUtilities.downloadFile import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfileAvatarId +import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL import org.session.libsession.utilities.Util.copy import org.session.libsession.utilities.Util.equals -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.streams.ProfileCipherInputStream import org.session.libsignal.utilities.Log @@ -19,12 +17,13 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream import java.security.SecureRandom +import java.util.concurrent.ConcurrentSkipListSet -class RetrieveProfileAvatarJob(private val profileAvatar: String?, private val recipientAddress: Address): Job { +class RetrieveProfileAvatarJob(private val profileAvatar: String?, val recipientAddress: Address): Job { override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 - override val maxFailureCount: Int = 0 + override val maxFailureCount: Int = 3 companion object { val TAG = RetrieveProfileAvatarJob::class.simpleName @@ -33,20 +32,30 @@ class RetrieveProfileAvatarJob(private val profileAvatar: String?, private val r // Keys used for database storage private const val PROFILE_AVATAR_KEY = "profileAvatar" private const val RECEIPIENT_ADDRESS_KEY = "recipient" + + val errorUrls = ConcurrentSkipListSet() + } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { + val delegate = delegate ?: return + if (profileAvatar in errorUrls) return delegate.handleJobFailed(this, dispatcherName, Exception("Profile URL 404'd this app instance")) val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage val recipient = Recipient.from(context, recipientAddress, true) val profileKey = recipient.resolve().profileKey if (profileKey == null || (profileKey.size != 32 && profileKey.size != 16)) { - Log.w(TAG, "Recipient profile key is gone!") - return + return delegate.handleJobFailedPermanently(this, dispatcherName, Exception("Recipient profile key is gone!")) } - if (AvatarHelper.avatarFileExists(context, recipient.resolve().address) && equals(profileAvatar, recipient.resolve().profileAvatar)) { + // Commit '78d1e9d' (fix: open group threads and avatar downloads) had this commented out so + // it's now limited to just the current user case + if ( + recipient.isLocalNumber && + AvatarHelper.avatarFileExists(context, recipient.resolve().address) && + equals(profileAvatar, recipient.resolve().profileAvatar) + ) { Log.w(TAG, "Already retrieved profile avatar: $profileAvatar") return } @@ -72,16 +81,23 @@ class RetrieveProfileAvatarJob(private val profileAvatar: String?, private val r val decryptDestination = File.createTempFile("avatar", ".jpg", context.cacheDir) copy(avatarStream, FileOutputStream(decryptDestination)) decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.address)) + + if (recipient.isLocalNumber) { + setProfileAvatarId(context, SecureRandom().nextInt()) + setProfilePictureURL(context, profileAvatar) + } + + storage.setProfileAvatar(recipient, profileAvatar) + } catch (e: Exception) { + Log.e("Loki", "Failed to download profile avatar", e) + if (failureCount + 1 >= maxFailureCount) { + errorUrls += profileAvatar + } + return delegate.handleJobFailed(this, dispatcherName, e) } finally { downloadDestination.delete() } - - if (recipient.isLocalNumber) { - setProfileAvatarId(context, SecureRandom().nextInt()) - setProfilePictureURL(context, profileAvatar) - } - - storage.setProfileAvatar(recipient, profileAvatar) + return delegate.handleJobSucceeded(this, dispatcherName) } override fun serialize(): Data { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index cfe792274f..46c87d5b90 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -16,6 +16,7 @@ class SessionJobManagerFactories { GroupAvatarDownloadJob.KEY to GroupAvatarDownloadJob.Factory(), BackgroundGroupAddJob.KEY to BackgroundGroupAddJob.Factory(), OpenGroupDeleteJob.KEY to OpenGroupDeleteJob.Factory(), + ConfigurationSyncJob.KEY to ConfigurationSyncJob.Factory() ) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt index d082ac7088..cc388b0376 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt @@ -20,7 +20,7 @@ class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job { const val THREAD_LENGTH_TRIGGER_SIZE = 2000 } - override fun execute(dispatcherName: String) { + override suspend fun execute(dispatcherName: String) { val context = MessagingModuleConfiguration.shared.context val trimmingEnabled = TextSecurePreferences.isThreadLengthTrimmingEnabled(context) val storage = MessagingModuleConfiguration.shared.storage diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt index d201daa98d..dd1d5f1852 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -1,6 +1,9 @@ package org.session.libsession.messaging.messages import com.google.protobuf.ByteString +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.utilities.GroupUtil import org.session.libsignal.protos.SignalServiceProtos @@ -11,6 +14,7 @@ abstract class Message { var receivedTimestamp: Long? = null var recipient: String? = null var sender: String? = null + var isSenderSelf: Boolean = false var groupPublicKey: String? = null var openGroupServerMessageID: Long? = null var serverHash: String? = null @@ -18,6 +22,17 @@ abstract class Message { open val ttl: Long = 14 * 24 * 60 * 60 * 1000 open val isSelfSendValid: Boolean = false + companion object { + fun getThreadId(message: Message, openGroupID: String?, storage: StorageProtocol, shouldCreateThread: Boolean): Long? { + val senderOrSync = when (message) { + is VisibleMessage -> message.syncTarget ?: message.sender!! + is ExpirationTimerUpdate -> message.syncTarget ?: message.sender!! + else -> message.sender!! + } + return storage.getThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID, createThread = shouldCreateThread) + } + } + open fun isValid(): Boolean { val sentTimestamp = sentTimestamp if (sentTimestamp != null && sentTimestamp <= 0) { return false } 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 30a47ab85b..eae9a76730 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 @@ -122,9 +122,9 @@ class ConfigurationMessage(var closedGroups: List, var openGroups: val displayName = TextSecurePreferences.getProfileName(context) ?: return null val profilePicture = TextSecurePreferences.getProfilePictureURL(context) val profileKey = ProfileKeyUtil.getProfileKey(context) - val groups = storage.getAllGroups() + val groups = storage.getAllGroups(includeInactive = false) for (group in groups) { - if (group.isClosedGroup) { + if (group.isClosedGroup && group.isActive) { if (!group.members.contains(Address.fromSerialized(storage.getUserPublicKey()!!))) continue val groupPublicKey = GroupUtil.doubleDecodeGroupID(group.encodedId).toHexString() val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: continue diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt new file mode 100644 index 0000000000..72b2474965 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt @@ -0,0 +1,36 @@ +package org.session.libsession.messaging.messages.control + +import com.google.protobuf.ByteString +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage + +class SharedConfigurationMessage(val kind: SharedConfigMessage.Kind, val data: ByteArray, val seqNo: Long): ControlMessage() { + + override val ttl: Long = 30 * 24 * 60 * 60 * 1000L + override val isSelfSendValid: Boolean = true + + companion object { + fun fromProto(proto: SignalServiceProtos.Content): SharedConfigurationMessage? { + if (!proto.hasSharedConfigMessage()) return null + val sharedConfig = proto.sharedConfigMessage + if (!sharedConfig.hasKind() || !sharedConfig.hasData()) return null + return SharedConfigurationMessage(sharedConfig.kind, sharedConfig.data.toByteArray(), sharedConfig.seqno) + } + } + + override fun isValid(): Boolean { + if (!super.isValid()) return false + return data.isNotEmpty() && seqNo >= 0 + } + + override fun toProto(): SignalServiceProtos.Content? { + val sharedConfigurationMessage = SharedConfigMessage.newBuilder() + .setKind(kind) + .setSeqno(seqNo) + .setData(ByteString.copyFrom(data)) + .build() + return SignalServiceProtos.Content.newBuilder() + .setSharedConfigMessage(sharedConfigurationMessage) + .build() + } +} \ 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 f5965d5f28..34022b7396 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 @@ -9,6 +9,7 @@ import org.session.libsession.messaging.messages.control.DataExtractionNotificat import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage @@ -34,13 +35,14 @@ object MessageReceiver { object NoThread: Error("Couldn't find thread for message.") object SelfSend: Error("Message addressed at self.") object InvalidGroupPublicKey: Error("Invalid group public key.") + object NoGroupThread: Error("No thread exists for this group.") object NoGroupKeyPair: Error("Missing group key pair.") object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") internal val isRetryable: Boolean = when (this) { is DuplicateMessage, is InvalidMessage, is UnknownMessage, is UnknownEnvelopeType, is InvalidSignature, is NoData, - is SenderBlocked, is SelfSend -> false + is SenderBlocked, is SelfSend, is NoGroupThread -> false else -> true } } @@ -51,6 +53,7 @@ object MessageReceiver { isOutgoing: Boolean? = null, otherBlindedPublicKey: String? = null, openGroupPublicKey: String? = null, + currentClosedGroups: Set? ): Pair { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() @@ -70,7 +73,7 @@ object MessageReceiver { } else { when (envelope.type) { SignalServiceProtos.Envelope.Type.SESSION_MESSAGE -> { - if (IdPrefix.fromValue(envelope.source) == IdPrefix.BLINDED) { + if (IdPrefix.fromValue(envelope.source)?.isBlinded() == true) { openGroupPublicKey ?: throw Error.InvalidGroupPublicKey otherBlindedPublicKey ?: throw Error.DecryptionFailed val decryptionResult = MessageDecrypter.decryptBlinded( @@ -139,6 +142,7 @@ object MessageReceiver { UnsendRequest.fromProto(proto) ?: MessageRequestResponse.fromProto(proto) ?: CallMessage.fromProto(proto) ?: + SharedConfigurationMessage.fromProto(proto) ?: VisibleMessage.fromProto(proto) ?: run { throw Error.UnknownMessage } @@ -147,6 +151,9 @@ object MessageReceiver { if (!message.isSelfSendValid && (sender == userPublicKey || isUserBlindedSender)) { throw Error.SelfSend } + if (sender == userPublicKey || isUserBlindedSender) { + message.isSenderSelf = true + } // Guard against control messages in open groups if (isOpenGroupMessage && message !is VisibleMessage) { throw Error.InvalidMessage @@ -167,12 +174,16 @@ object MessageReceiver { // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround // for this issue. - if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) { + if (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet())) { + throw Error.NoGroupThread + } + if ((message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) || message is SharedConfigurationMessage) { // Allow duplicates in this case to avoid the following situation: // • The app performed a background poll or received a push notification // • This method was invoked and the received message timestamps table was updated // • Processing wasn't finished // • The user doesn't see the new closed group + // also allow shared configuration messages to be duplicates since we track hashes separately use seqno for conflict resolution } else { if (storage.isDuplicateMessage(envelope.timestamp)) { throw Error.DuplicateMessage } storage.addReceivedMessageTimestamp(envelope.timestamp) 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 fa0a49a647..804b2f15be 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 @@ -13,6 +13,7 @@ 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.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.Quote @@ -61,7 +62,7 @@ object MessageSender { } // Convenience - fun send(message: Message, destination: Destination, isSyncMessage: Boolean = false): Promise { + fun send(message: Message, destination: Destination, isSyncMessage: Boolean): Promise { return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { @@ -69,71 +70,115 @@ object MessageSender { } } + // One-on-One Chats & Closed Groups + @Throws(Exception::class) + fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { + val storage = MessagingModuleConfiguration.shared.storage + val userPublicKey = storage.getUserPublicKey() + // Set the timestamp, sender and recipient + val messageSendTime = SnodeAPI.nowWithOffset + if (message.sentTimestamp == null) { + message.sentTimestamp = + messageSendTime // Visible messages will already have their sent timestamp set + } + + message.sender = userPublicKey + + when (destination) { + is Destination.Contact -> message.recipient = destination.publicKey + is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey + else -> throw IllegalStateException("Destination should not be an open group.") + } + + val isSelfSend = (message.recipient == userPublicKey) + // Validate the message + if (!message.isValid()) { + throw Error.InvalidMessage + } + // Stop here if this is a self-send, unless it's: + // • a configuration message + // • a sync message + // • a closed group control message of type `new` + var isNewClosedGroupControlMessage = false + if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = + true + if (isSelfSend + && message !is ConfigurationMessage + && !isSyncMessage + && !isNewClosedGroupControlMessage + && message !is UnsendRequest + && message !is SharedConfigurationMessage + ) { + throw Error.InvalidMessage + } + // Attach the user's profile if needed + if (message is VisibleMessage) { + message.profile = storage.getUserProfile() + } + if (message is MessageRequestResponse) { + message.profile = storage.getUserProfile() + } + // Convert it to protobuf + val proto = message.toProto() ?: throw Error.ProtoConversionFailed + // Serialize the protobuf + val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) + // Encrypt the serialized protobuf + val ciphertext = when (destination) { + is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) + is Destination.ClosedGroup -> { + val encryptionKeyPair = + MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair( + destination.groupPublicKey + )!! + MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) + } + else -> throw IllegalStateException("Destination should not be open group.") + } + // Wrap the result + val kind: SignalServiceProtos.Envelope.Type + val senderPublicKey: String + when (destination) { + is Destination.Contact -> { + kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE + senderPublicKey = "" + } + is Destination.ClosedGroup -> { + kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE + senderPublicKey = destination.groupPublicKey + } + else -> throw IllegalStateException("Destination should not be open group.") + } + val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) + val base64EncodedData = Base64.encodeBytes(wrappedMessage) + // Send the result + return SnodeMessage( + message.recipient!!, + base64EncodedData, + message.ttl, + messageSendTime + ) + } + // One-on-One Chats & Closed Groups private fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false): Promise { val deferred = deferred() val promise = deferred.promise val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() - // Set the timestamp, sender and recipient - if (message.sentTimestamp == null) { - message.sentTimestamp = SnodeAPI.nowWithOffset // Visible messages will already have their sent timestamp set - } - val messageSendTime = SnodeAPI.nowWithOffset + // recipient will be set later, so initialize it as a function here + val isSelfSend = { message.recipient == userPublicKey } - message.sender = userPublicKey - val isSelfSend = (message.recipient == userPublicKey) // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error, isSyncMessage) - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { + if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend()) { SnodeModule.shared.broadcaster.broadcast("messageFailed", message.sentTimestamp!!) } deferred.reject(error) } try { - when (destination) { - is Destination.Contact -> message.recipient = destination.publicKey - is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey - else -> throw IllegalStateException("Destination should not be an open group.") - } - // Validate the message - if (!message.isValid()) { throw Error.InvalidMessage } - // Stop here if this is a self-send, unless it's: - // • a configuration message - // • a sync message - // • a closed group control message of type `new` - var isNewClosedGroupControlMessage = false - if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = true - if (isSelfSend && message !is ConfigurationMessage && !isSyncMessage && !isNewClosedGroupControlMessage && message !is UnsendRequest) { - handleSuccessfulMessageSend(message, destination) - deferred.resolve(Unit) - return promise - } - // Attach the user's profile if needed - if (message is VisibleMessage) { - message.profile = storage.getUserProfile() - } - if (message is MessageRequestResponse) { - message.profile = storage.getUserProfile() - } - // Convert it to protobuf - val proto = message.toProto() ?: throw Error.ProtoConversionFailed - // Serialize the protobuf - val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) - // Encrypt the serialized protobuf - val ciphertext = when (destination) { - is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) - is Destination.ClosedGroup -> { - val encryptionKeyPair = MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!! - MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) - } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Wrap the result - val kind: SignalServiceProtos.Envelope.Type - val senderPublicKey: String + val snodeMessage = buildWrappedMessageToSnode(destination, message, isSyncMessage) // TODO: this might change in future for config messages val forkInfo = SnodeAPI.forkInfo val namespaces: List = when { @@ -143,29 +188,6 @@ object MessageSender { && forkInfo.hasNamespaces() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP, Namespace.DEFAULT) else -> listOf(Namespace.DEFAULT) } - when (destination) { - is Destination.Contact -> { - kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE - senderPublicKey = "" - } - is Destination.ClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.groupPublicKey - } - else -> throw IllegalStateException("Destination should not be open group.") - } - val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) - // Send the result - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("calculatingPoW", messageSendTime) - } - val base64EncodedData = Base64.encodeBytes(wrappedMessage) - // Send the result - val timestamp = messageSendTime + SnodeAPI.clockOffset - val snodeMessage = SnodeMessage(message.recipient!!, base64EncodedData, message.ttl, timestamp) - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("sendingMessage", messageSendTime) - } namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises -> var isSuccess = false val promiseCount = promises.size @@ -174,9 +196,6 @@ object MessageSender { promise.success { if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds isSuccess = true - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("messageSent", messageSendTime) - } val hash = it["hash"] as? String message.serverHash = hash handleSuccessfulMessageSend(message, destination, isSyncMessage) @@ -414,24 +433,24 @@ object MessageSender { @JvmStatic fun send(message: Message, address: Address) { - val threadID = MessagingModuleConfiguration.shared.storage.getOrCreateThreadIdFor(address) + val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) val job = MessageSendJob(message, destination) JobQueue.shared.add(job) } - fun sendNonDurably(message: VisibleMessage, attachments: List, address: Address): Promise { + fun sendNonDurably(message: VisibleMessage, attachments: List, address: Address, isSyncMessage: Boolean): Promise { val attachmentIDs = MessagingModuleConfiguration.shared.messageDataProvider.getAttachmentIDsFor(message.id!!) message.attachmentIDs.addAll(attachmentIDs) - return sendNonDurably(message, address) + return sendNonDurably(message, address, isSyncMessage) } - fun sendNonDurably(message: Message, address: Address): Promise { - val threadID = MessagingModuleConfiguration.shared.storage.getOrCreateThreadIdFor(address) + fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean): Promise { + val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) - return send(message, destination) + return send(message, destination, isSyncMessage) } // Closed groups diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index f62fd5a93a..a98b6b1b6b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -18,14 +18,14 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.Curve import org.session.libsignal.crypto.ecc.ECKeyPair -import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.ThreadUtils +import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.ThreadUtils -import org.session.libsignal.utilities.Log import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -60,16 +60,20 @@ fun MessageSender.create(name: String, members: Collection): Promise): Promise): Promise, name: String) { - val context = MessagingModuleConfiguration.shared.context - val storage = MessagingModuleConfiguration.shared.storage - val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) - val group = storage.getGroup(groupID) ?: run { - Log.d("Loki", "Can't update nonexistent closed group.") - throw Error.NoThread - } - // Update name if needed - if (name != group.title) { setName(groupPublicKey, name) } - // Add members if needed - val addedMembers = members - group.members.map { it.serialize() } - if (!addedMembers.isEmpty()) { addMembers(groupPublicKey, addedMembers) } - // Remove members if needed - val removedMembers = group.members.map { it.serialize() } - members - if (removedMembers.isEmpty()) { removeMembers(groupPublicKey, removedMembers) } -} - fun MessageSender.setName(groupPublicKey: String, newName: String) { val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage @@ -252,15 +240,15 @@ fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true): Pro val sentTime = SnodeAPI.nowWithOffset closedGroupControlMessage.sentTimestamp = sentTime storage.setActive(groupID, false) - sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID)).success { + sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID), isSyncMessage = false).success { // Notify the user val infoType = SignalServiceGroup.Type.QUIT - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) if (notifyUser) { + val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) storage.insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime) } // Remove the group private key and unsubscribe from PNs - MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) + MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, true) deferred.resolve(Unit) }.fail { storage.setActive(groupID, true) @@ -292,7 +280,7 @@ fun MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey: String, ta // Distribute it sendEncryptionKeyPair(groupPublicKey, newKeyPair, targetMembers)?.success { // Store it * after * having sent out the message to the group - storage.addClosedGroupEncryptionKeyPair(newKeyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(newKeyPair, groupPublicKey, SnodeAPI.nowWithOffset) pendingKeyPairs[groupPublicKey] = Optional.absent() } } @@ -312,7 +300,8 @@ fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKe val closedGroupControlMessage = ClosedGroupControlMessage(kind) closedGroupControlMessage.sentTimestamp = sentTime return if (force) { - MessageSender.sendNonDurably(closedGroupControlMessage, Address.fromSerialized(destination)) + val isSync = MessagingModuleConfiguration.shared.storage.getUserPublicKey() == destination + MessageSender.sendNonDurably(closedGroupControlMessage, Address.fromSerialized(destination), isSyncMessage = isSync) } else { MessageSender.send(closedGroupControlMessage, Address.fromSerialized(destination)) null 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 523ad450b1..19278aadde 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,11 +1,11 @@ package org.session.libsession.messaging.sending_receiving import android.text.TextUtils +import network.loki.messenger.libsession_util.ConfigBase import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage @@ -42,6 +42,7 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -58,7 +59,10 @@ internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { return recipient.isBlocked } -fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, openGroupID: String?) { +fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, threadId: Long, openGroupID: String?) { + // Do nothing if the message was outdated + if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return } + when (message) { is ReadReceipt -> handleReadReceipt(message) is TypingIndicator -> handleTypingIndicator(message) @@ -68,8 +72,8 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, is ConfigurationMessage -> handleConfigurationMessage(message) is UnsendRequest -> handleUnsendRequest(message) is MessageRequestResponse -> handleMessageRequestResponse(message) - is VisibleMessage -> handleVisibleMessage(message, proto, openGroupID, - runIncrement = true, + is VisibleMessage -> handleVisibleMessage( + message, proto, openGroupID, threadId, runThreadUpdate = true, runProfileUpdate = true ) @@ -77,6 +81,33 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, } } +fun MessageReceiver.messageIsOutdated(message: Message, threadId: Long, openGroupID: String?): Boolean { + when (message) { + is ReadReceipt -> return false // No visible artifact created so better to keep for more reliable read states + is UnsendRequest -> return false // We should always process the removal of messages just in case + } + + // Determine the state of the conversation and the validity of the message + val storage = MessagingModuleConfiguration.shared.storage + val userPublicKey = storage.getUserPublicKey()!! + val threadRecipient = storage.getRecipientForThread(threadId) + val conversationVisibleInConfig = storage.conversationInConfig( + if (message.groupPublicKey == null) threadRecipient?.address?.serialize() else null, + message.groupPublicKey, + openGroupID, + true + ) + val canPerformChange = storage.canPerformConfigChange( + if (threadRecipient?.address?.serialize() == userPublicKey) SharedConfigMessage.Kind.USER_PROFILE.name else SharedConfigMessage.Kind.CONTACTS.name, + userPublicKey, + message.sentTimestamp!! + ) + + // If the thread is visible or the message was sent more recently than the last config message (minus + // buffer period) then we should process the message, if not then the message is outdated + return (!conversationVisibleInConfig && !canPerformChange) +} + // region Control Messages private fun MessageReceiver.handleReadReceipt(message: ReadReceipt) { val context = MessagingModuleConfiguration.shared.context @@ -129,6 +160,7 @@ private fun MessageReceiver.handleDataExtractionNotification(message: DataExtrac if (message.groupPublicKey != null) return val storage = MessagingModuleConfiguration.shared.storage val senderPublicKey = message.sender!! + val notification: DataExtractionNotificationInfoMessage = when(message.kind) { is DataExtractionNotification.Kind.Screenshot -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.SCREENSHOT) is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED) @@ -149,11 +181,17 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { TextSecurePreferences.setConfigurationMessageSynced(context, true) TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!) + val isForceSync = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(isForceSync, currentTime)) { + TextSecurePreferences.setHasLegacyConfig(context, true) + if (!firstTimeSync) return + } val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() for (closedGroup in message.closedGroups) { if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { // just handle the closed group encryption key pairs to avoid sync'd devices getting out of sync - storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey) + storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey, message.sentTimestamp!!) } else if (firstTimeSync) { // only handle new closed group if it's first time sync handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name, @@ -166,9 +204,9 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { .replace(OpenGroupApi.httpDefaultServer, OpenGroupApi.defaultServer) }) { if (allV2OpenGroups.contains(openGroup)) continue - Log.d("OpenGroup", "All open groups doesn't contain $openGroup") + Log.d("OpenGroup", "All open groups doesn't contain open group") if (!storage.hasBackgroundGroupAddJob(openGroup)) { - Log.d("OpenGroup", "Doesn't contain background job for $openGroup, adding") + Log.d("OpenGroup", "Doesn't contain background job for open group, adding") JobQueue.shared.add(BackgroundGroupAddJob(openGroup)) } } @@ -182,10 +220,7 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) { val profileKey = Base64.encodeBytes(message.profileKey) ProfileKeyUtil.setEncodedProfileKey(context, profileKey) - profileManager.setProfileKey(context, recipient, message.profileKey) - if (!message.profilePicture.isNullOrEmpty() && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) { - JobQueue.shared.add(RetrieveProfileAvatarJob(message.profilePicture!!, recipient.address)) - } + profileManager.setProfilePicture(context, recipient, message.profilePicture, message.profileKey) } storage.addContacts(message.contacts) } @@ -215,24 +250,28 @@ fun handleMessageRequestResponse(message: MessageRequestResponse) { } //endregion -fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, - proto: SignalServiceProtos.Content, - openGroupID: String?, - runIncrement: Boolean, - runThreadUpdate: Boolean, - runProfileUpdate: Boolean): Long? { +fun MessageReceiver.handleVisibleMessage( + message: VisibleMessage, + proto: SignalServiceProtos.Content, + openGroupID: String?, + threadId: Long, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean +): Long? { val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context val userPublicKey = storage.getUserPublicKey() val messageSender: String? = message.sender + + // Do nothing if the message was outdated + if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return null } + // Get or create thread // FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet // exist. This is intentional, but it's very non-obvious. - val threadID = storage.getOrCreateThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID) - if (threadID < 0) { + val threadID = storage.getThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID, createThread = true) // Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread - throw MessageReceiver.Error.NoThread - } + ?: throw MessageReceiver.Error.NoThread val threadRecipient = storage.getRecipientForThread(threadID) val userBlindedKey = openGroupID?.let { val openGroup = storage.getOpenGroup(threadID) ?: return@let null @@ -259,9 +298,10 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey)) if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { - profileManager.setProfileKey(context, recipient, newProfileKey!!) + profileManager.setProfilePicture(context, recipient, profile.profilePictureURL, newProfileKey) profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) - profileManager.setProfilePictureURL(context, recipient, profile.profilePictureURL!!) + } else if (newProfileKey == null || newProfileKey.isEmpty() || profile.profilePictureURL.isNullOrEmpty()) { + profileManager.setProfilePicture(context, recipient, null, null) } } } @@ -344,7 +384,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, message.threadID = threadID val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, - attachments, runIncrement, runThreadUpdate + attachments, runThreadUpdate ) ?: return null val openGroupServerID = message.openGroupServerMessageID if (openGroupServerID != null) { @@ -437,12 +477,34 @@ private fun MessageReceiver.handleClosedGroupControlMessage(message: ClosedGroup is ClosedGroupControlMessage.Kind.MembersRemoved -> handleClosedGroupMembersRemoved(message) is ClosedGroupControlMessage.Kind.MemberLeft -> handleClosedGroupMemberLeft(message) } + if ( + message.kind !is ClosedGroupControlMessage.Kind.New && + MessagingModuleConfiguration.shared.storage.canPerformConfigChange( + SharedConfigMessage.Kind.GROUPS.name, + MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!, + message.sentTimestamp!! + ) + ) { + // update the config + val closedGroupPublicKey = message.getPublicKey() + val storage = MessagingModuleConfiguration.shared.storage + storage.updateGroupConfig(closedGroupPublicKey) + } } +private fun ClosedGroupControlMessage.getPublicKey(): String = kind!!.let { when (it) { + is ClosedGroupControlMessage.Kind.New -> it.publicKey.toByteArray().toHexString() + is ClosedGroupControlMessage.Kind.EncryptionKeyPair -> it.publicKey?.toByteArray()?.toHexString() ?: groupPublicKey!! + is ClosedGroupControlMessage.Kind.MemberLeft -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.MembersAdded -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.MembersRemoved -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.NameChange -> groupPublicKey!! +}} + private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) { val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return val recipient = Recipient.from(MessagingModuleConfiguration.shared.context, Address.fromSerialized(message.sender!!), false) - if (!recipient.isApproved && !recipient.isLocalNumber) return + if (!recipient.isApproved && !recipient.isLocalNumber) return Log.e("Loki", "not accepting new closed group from unapproved recipient") val groupPublicKey = kind.publicKey.toByteArray().toHexString() val members = kind.members.map { it.toByteArray().toHexString() } val admins = kind.admins.map { it.toByteArray().toHexString() } @@ -453,10 +515,24 @@ private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMess private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPublicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: List, admins: List, formationTimestamp: Long, expireTimer: Int) { val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage - val userPublicKey = TextSecurePreferences.getLocalNumber(context) - // Create the group + val userPublicKey = storage.getUserPublicKey()!! val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val groupExists = storage.getGroup(groupID) != null + + if (!storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, sentTimestamp)) { + // If the closed group already exists then store the encryption keys (since the config only stores + // the latest key we won't be able to decrypt older messages if we were added to the group within + // the last two weeks and the key has been rotated - unfortunately if the user was added more than + // two weeks ago and the keys were rotated within the last two weeks then we won't be able to decrypt + // messages received before the key rotation) + if (groupExists) { + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) + storage.updateGroupConfig(groupPublicKey) + } + return + } + + // Create the group if (groupExists) { // Update the group if (!storage.isGroupActive(groupPublicKey)) { @@ -475,18 +551,15 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli // Add the group to the user's set of public keys to poll for storage.addClosedGroupPublicKey(groupPublicKey) // Store the encryption key pair - storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) + storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), formationTimestamp, encryptionKeyPair) // Set expiration timer storage.setExpirationTimer(groupID, expireTimer) // Notify the PN server - PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, storage.getUserPublicKey()!!) - // Notify the user - if (userPublicKey == sender && !groupExists) { - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) - storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTimestamp) - } else if (userPublicKey != sender) { - storage.insertIncomingInfoMessage(context, sender, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, sentTimestamp) - } + PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey) + // Create thread + val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + storage.setThreadDate(threadId, formationTimestamp) // Start polling ClosedGroupPollerV2.shared.startPolling(groupPublicKey) } @@ -527,7 +600,7 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr Log.d("Loki", "Ignoring duplicate closed group encryption key pair.") return } - storage.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey, message.sentTimestamp!!) Log.d("Loki", "Received a new closed group encryption key pair.") } @@ -555,7 +628,12 @@ private fun MessageReceiver.handleClosedGroupNameChanged(message: ClosedGroupCon val members = group.members.map { it.serialize() } val admins = group.admins.map { it.serialize() } val name = kind.name - storage.updateTitle(groupID, name) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey!!, message.sentTimestamp!!)) { + storage.updateTitle(groupID, name) + } + // Notify the user if (userPublicKey == senderPublicKey) { val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) @@ -589,12 +667,16 @@ private fun MessageReceiver.handleClosedGroupMembersAdded(message: ClosedGroupCo val updateMembers = kind.members.map { it.toByteArray().toHexString() } val newMembers = members + updateMembers - storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) - // Update zombie members in case the added members are zombies - val zombies = storage.getZombieMembers(groupID) - if (zombies.intersect(updateMembers).isNotEmpty()) { - storage.setZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) }) + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) + + // Update zombie members in case the added members are zombies + val zombies = storage.getZombieMembers(groupID) + if (zombies.intersect(updateMembers).isNotEmpty()) { + storage.setZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) }) + } } // Notify the user @@ -676,13 +758,18 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup Log.d("Loki", "Received a MEMBERS_REMOVED instead of a MEMBERS_LEFT from sender: $senderPublicKey.") } val wasCurrentUserRemoved = userPublicKey in removedMembers - // Admin should send a MEMBERS_LEFT message but handled here just in case - if (didAdminLeave || wasCurrentUserRemoved) { - disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) - } else { - storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) - // Update zombie members - storage.setZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) }) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + // Admin should send a MEMBERS_LEFT message but handled here just in case + if (didAdminLeave || wasCurrentUserRemoved) { + disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, true) + return + } else { + storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) + // Update zombie members + storage.setZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) }) + } } // Notify the user @@ -731,24 +818,30 @@ private fun MessageReceiver.handleClosedGroupMemberLeft(message: ClosedGroupCont val didAdminLeave = admins.contains(senderPublicKey) val updatedMemberList = members - senderPublicKey val userLeft = (userPublicKey == senderPublicKey) - if (didAdminLeave || userLeft) { - disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) - } else { - storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) }) - // Update zombie members - val zombies = storage.getZombieMembers(groupID) - storage.setZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) }) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + if (didAdminLeave || userLeft) { + disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, delete = userLeft) + + if (userLeft) { + return + } + } else { + storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) }) + // Update zombie members + val zombies = storage.getZombieMembers(groupID) + storage.setZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) }) + } } + // Notify the user - if (userLeft) { - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) - storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.QUIT, name, members, admins, threadID, message.sentTimestamp!!) - } else { + if (!userLeft) { storage.insertIncomingInfoMessage(context, senderPublicKey, groupID, SignalServiceGroup.Type.QUIT, name, members, admins, message.sentTimestamp!!) } } -private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { +private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { val oldMembers = group.members.map { it.serialize() } // Check that the message isn't from before the group was created if (group.formationTimestamp > sentTimestamp) { @@ -763,7 +856,7 @@ private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPu return true } -fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, groupID: String, userPublicKey: String) { +fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean) { val storage = MessagingModuleConfiguration.shared.storage storage.removeClosedGroupPublicKey(groupPublicKey) // Remove the key pairs @@ -775,5 +868,11 @@ fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, grou PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) // Stop polling ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + + if (delete) { + val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + storage.cancelPendingMessageSendJobs(threadId) + storage.deleteConversation(threadId) + } } // endregion 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 387381c9cc..b9baadcaba 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 @@ -12,6 +12,7 @@ import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob +import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.Endpoint @@ -169,6 +170,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S is Endpoint.Outbox, is Endpoint.OutboxSince -> { handleDirectMessages(server, true, response.body as List) } + else -> { /* We don't care about the result of any other calls (won't be polled for) */} } if (secondToLastJob == null && !isCaughtUp) { isCaughtUp = true @@ -205,7 +207,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S val storage = MessagingModuleConfiguration.shared.storage storage.setServerCapabilities(server, capabilities.capabilities) } - + private fun handleMessages( server: String, roomToken: String, @@ -260,7 +262,8 @@ class OpenGroupPoller(private val server: String, private val executorService: S null, fromOutbox, if (fromOutbox) it.recipient else it.sender, - serverPublicKey + serverPublicKey, + emptySet() // this shouldn't be necessary as we are polling open groups here ) if (fromOutbox) { val mapping = mappingCache[it.recipient] ?: storage.getOrCreateBlindedIdMapping( @@ -277,7 +280,8 @@ class OpenGroupPoller(private val server: String, private val executorService: S } mappingCache[it.recipient] = mapping } - MessageReceiver.handle(message, proto, null) + val threadId = Message.getThreadId(message, null, MessagingModuleConfiguration.shared.storage, false) + MessageReceiver.handle(message, proto, threadId ?: -1, null) } catch (e: Exception) { Log.e("Loki", "Couldn't handle direct message", e) } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 4a39b70c0f..f0b20436fc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -1,5 +1,14 @@ package org.session.libsession.messaging.sending_receiving.pollers +import android.util.SparseArray +import androidx.core.util.valueIterator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile import nl.komponents.kovenant.Deferred import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred @@ -10,17 +19,23 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeModule +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import java.security.SecureRandom import java.util.Timer import java.util.TimerTask +import kotlin.time.Duration.Companion.days private class PromiseCanceledException : Exception("Promise canceled.") -class Poller { +class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Timer) { var userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() ?: "" private var hasStarted: Boolean = false private val usedSnodes: MutableSet = mutableSetOf() @@ -97,23 +112,159 @@ class Poller { } } + private fun processPersonalMessages(snode: Snode, rawMessages: RawResponse) { + val messages = SnodeAPI.parseRawMessagesResponse(rawMessages, snode, userPublicKey) + val parameters = messages.map { (envelope, serverHash) -> + MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + } + parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> + val job = BatchMessageReceiveJob(chunk) + JobQueue.shared.add(job) + } + } + + private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) { + if (forConfigObject == null) return + + val messages = SnodeAPI.parseRawMessagesResponse( + rawMessages, + snode, + userPublicKey, + namespace, + updateLatestHash = true, + updateStoredHashes = true, + ) + + if (messages.isEmpty()) { + // no new messages to process + return + } + + var latestMessageTimestamp: Long? = null + messages.forEach { (envelope, hash) -> + try { + val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), + // assume no groups in personal poller messages + openGroupServerID = null, currentClosedGroups = emptySet() + ) + // sanity checks + if (message !is SharedConfigurationMessage) { + Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}") + return@forEach + } + forConfigObject.merge(hash!! to message.data) + latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } + } catch (e: Exception) { + Log.e("Loki", e) + } + } + // process new results + if (forConfigObject.needsDump()) { + configFactory.persist(forConfigObject, latestMessageTimestamp ?: SnodeAPI.nowWithOffset) + } + } + private fun poll(snode: Snode, deferred: Deferred): Promise { if (!hasStarted) { return Promise.ofFail(PromiseCanceledException()) } - return SnodeAPI.getRawMessages(snode, userPublicKey).bind { rawResponse -> - isCaughtUp = true - if (deferred.promise.isDone()) { - task { Unit } // The long polling connection has been canceled; don't recurse - } else { - val messages = SnodeAPI.parseRawMessagesResponse(rawResponse, snode, userPublicKey) - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + return task { + runBlocking(Dispatchers.IO) { + val requestSparseArray = SparseArray() + // get messages + SnodeAPI.buildAuthenticatedRetrieveBatchRequest(snode, userPublicKey, maxSize = -2)!!.also { personalMessages -> + // namespaces here should always be set + requestSparseArray[personalMessages.namespace!!] = personalMessages } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - val job = BatchMessageReceiveJob(chunk) - JobQueue.shared.add(job) + // get the latest convo info volatile + val hashesToExtend = mutableSetOf() + configFactory.getUserConfigs().mapNotNull { config -> + hashesToExtend += config.currentHashes() + SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + snode, userPublicKey, + config.configNamespace(), + maxSize = -8 + ) + }.forEach { request -> + // namespaces here should always be set + requestSparseArray[request.namespace!!] = request } - poll(snode, deferred) + val requests = + requestSparseArray.valueIterator().asSequence().toMutableList() + + if (hashesToExtend.isNotEmpty()) { + SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( + messageHashes = hashesToExtend.toList(), + publicKey = userPublicKey, + newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds, + extend = true + )?.let { extensionRequest -> + requests += extensionRequest + } + } + + SnodeAPI.getRawBatchResponse(snode, userPublicKey, requests).bind { rawResponses -> + isCaughtUp = true + if (deferred.promise.isDone()) { + return@bind Promise.ofSuccess(Unit) + } else { + val responseList = (rawResponses["results"] as List) + // in case we had null configs, the array won't be fully populated + // index of the sparse array key iterator should be the request index, with the key being the namespace + // TODO: add in specific ordering of config namespaces for processing + listOfNotNull( + configFactory.user?.configNamespace(), + configFactory.contacts?.configNamespace(), + configFactory.userGroups?.configNamespace(), + configFactory.convoVolatile?.configNamespace() + ).map { + it to requestSparseArray.indexOfKey(it) + }.filter { (_, i) -> i >= 0 }.forEach { (key, requestIndex) -> + responseList.getOrNull(requestIndex)?.let { rawResponse -> + if (rawResponse["code"] as? Int != 200) { + Log.e("Loki", "Batch sub-request had non-200 response code, returned code ${(rawResponse["code"] as? Int) ?: "[unknown]"}") + return@forEach + } + val body = rawResponse["body"] as? RawResponse + if (body == null) { + Log.e("Loki", "Batch sub-request didn't contain a body") + return@forEach + } + if (key == Namespace.DEFAULT) { + return@forEach // continue, skip default namespace + } else { + when (ConfigBase.kindFor(key)) { + UserProfile::class.java -> processConfig(snode, body, key, configFactory.user) + Contacts::class.java -> processConfig(snode, body, key, configFactory.contacts) + ConversationVolatileConfig::class.java -> processConfig(snode, body, key, configFactory.convoVolatile) + UserGroupsConfig::class.java -> processConfig(snode, body, key, configFactory.userGroups) + } + } + } + } + + // the first response will be the personal messages (we want these to be processed after config messages) + val personalResponseIndex = requestSparseArray.indexOfKey(Namespace.DEFAULT) + if (personalResponseIndex >= 0) { + responseList.getOrNull(personalResponseIndex)?.let { rawResponse -> + if (rawResponse["code"] as? Int != 200) { + Log.e("Loki", "Batch sub-request for personal messages had non-200 response code, returned code ${(rawResponse["code"] as? Int) ?: "[unknown]"}") + } else { + val body = rawResponse["body"] as? RawResponse + if (body == null) { + Log.e("Loki", "Batch sub-request for personal messages didn't contain a body") + } else { + processPersonalMessages(snode, body) + } + } + } + } + + poll(snode, deferred) + } + }.fail { + Log.e("Loki", "Failed to get raw batch response", it) + poll(snode, deferred) + } } } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt index 35328b9742..e4db056d8e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt @@ -74,6 +74,7 @@ object UpdateMessageBuilder { context.getString(R.string.ConversationItem_group_action_left, senderName) } } + is UpdateMessageData.Kind.OpenGroupInvitation -> { /*Handled externally*/ } } return message } 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 087c8e29d3..8851dfc2b3 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -419,6 +419,8 @@ object OnionRequestAPI { Log.d("Loki","Destination server returned ${exception.statusCode}") } else if (message == "Loki Server error") { Log.d("Loki", "message was $message") + } else if (exception.statusCode == 404) { + // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here } else { // Only drop snode/path if not receiving above two exception cases handleUnspecificError() } @@ -446,8 +448,8 @@ object OnionRequestAPI { val payloadData = JsonUtil.toJson(payload).toByteArray() return sendOnionRequest(Destination.Snode(snode), payloadData, version).recover { exception -> val error = when (exception) { - is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) is HTTPRequestFailedAtDestinationException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) + is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) else -> null } if (error != null) { throw error } 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 ebd66d3a3f..b1a274773c 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -28,12 +28,12 @@ import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.prettifiedDescription import org.session.libsignal.utilities.retryIfNeeded import java.security.SecureRandom -import java.util.Date import java.util.Locale import kotlin.collections.component1 import kotlin.collections.component2 @@ -102,6 +102,14 @@ object SnodeAPI { object ValidationFailed : Error("ONS name validation failed.") } + // Batch + data class SnodeBatchRequestInfo( + val method: String, + val params: Map, + @Transient + val namespace: Int? + ) // assume signatures, pubkey and namespaces are attached in parameters if required + // Internal API internal fun invoke( method: Snode.Method, @@ -319,26 +327,32 @@ object SnodeAPI { fun getRawMessages(snode: Snode, publicKey: String, requiresAuth: Boolean = true, namespace: Int = 0): RawResponsePromise { // Get last message hash val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" - val parameters = mutableMapOf( + val parameters = mutableMapOf( "pubKey" to publicKey, "last_hash" to lastHashValue, ) // Construct signature if (requiresAuth) { val userED25519KeyPair = try { - MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) + MessagingModuleConfiguration.shared.getUserED25519KeyPair() + ?: return Promise.ofFail(Error.NoKeyPair) } catch (e: Exception) { Log.e("Loki", "Error getting KeyPair", e) return Promise.ofFail(Error.NoKeyPair) } - val timestamp = Date().time + SnodeAPI.clockOffset + val timestamp = System.currentTimeMillis() + clockOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) val verificationData = if (namespace != 0) "retrieve$namespace$timestamp".toByteArray() else "retrieve$timestamp".toByteArray() try { - sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userED25519KeyPair.secretKey.asBytes + ) } catch (exception: Exception) { return Promise.ofFail(Error.SigningFailed) } @@ -354,7 +368,251 @@ object SnodeAPI { } // Make the request - return invoke(Snode.Method.GetMessages, snode, parameters, publicKey) + return invoke(Snode.Method.Retrieve, snode, parameters, publicKey) + } + + fun buildAuthenticatedStoreBatchInfo(publicKey: String, namespace: Int, message: SnodeMessage): SnodeBatchRequestInfo? { + val params = mutableMapOf() + // load the message data params into the sub request + // currently loads: + // pubKey + // data + // ttl + // timestamp + params.putAll(message.toJSON()) + params["namespace"] = namespace + + // used for sig generation since it is also the value used in timestamp parameter + val messageTimestamp = message.timestamp + + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + val verificationData = "store$namespace$messageTimestamp".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + } + // timestamp already set + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + return SnodeBatchRequestInfo( + Snode.Method.SendMessage.rawValue, + params, + namespace + ) + } + + /** + * Message hashes can be shared across multiple namespaces (for a single public key destination) + * @param publicKey the destination's identity public key to delete from (05...) + * @param messageHashes a list of stored message hashes to delete from the server + * @param required indicates that *at least one* message in the list is deleted from the server, otherwise it will return 404 + */ + fun buildAuthenticatedDeleteBatchInfo(publicKey: String, messageHashes: List, required: Boolean = false): SnodeBatchRequestInfo? { + val params = mutableMapOf( + "pubkey" to publicKey, + "required" to required, // could be omitted technically but explicit here + "messages" to messageHashes + ) + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + val verificationData = "delete${messageHashes.joinToString("")}".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + return SnodeBatchRequestInfo( + Snode.Method.DeleteMessage.rawValue, + params, + null + ) + } + + fun buildAuthenticatedRetrieveBatchRequest(snode: Snode, publicKey: String, namespace: Int = 0, maxSize: Int? = null): SnodeBatchRequestInfo? { + val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" + val params = mutableMapOf( + "pubkey" to publicKey, + "last_hash" to lastHashValue, + ) + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val timestamp = System.currentTimeMillis() + clockOffset + val signature = ByteArray(Sign.BYTES) + val verificationData = if (namespace == 0) "retrieve$timestamp".toByteArray() + else "retrieve$namespace$timestamp".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["timestamp"] = timestamp + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + if (namespace != 0) { + params["namespace"] = namespace + } + if (maxSize != null) { + params["max_size"] = maxSize + } + return SnodeBatchRequestInfo( + Snode.Method.Retrieve.rawValue, + params, + namespace + ) + } + + fun buildAuthenticatedAlterTtlBatchRequest( + messageHashes: List, + newExpiry: Long, + publicKey: String, + shorten: Boolean = false, + extend: Boolean = false): SnodeBatchRequestInfo? { + val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) ?: return null + return SnodeBatchRequestInfo( + Snode.Method.Expire.rawValue, + params, + null + ) + } + + fun getRawBatchResponse(snode: Snode, publicKey: String, requests: List, sequence: Boolean = false): RawResponsePromise { + val parameters = mutableMapOf( + "requests" to requests + ) + return invoke(if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode, parameters, publicKey).success { rawResponses -> + val responseList = (rawResponses["results"] as List) + responseList.forEachIndexed { index, response -> + if (response["code"] as? Int != 200) { + Log.w("Loki", "response code was not 200") + handleSnodeError( + response["code"] as? Int ?: 0, + response, + snode, + publicKey + ) + } + } + } + } + + fun getExpiries(messageHashes: List, publicKey: String) : RawResponsePromise { + val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(NullPointerException("No user key pair")) + return retryIfNeeded(maxRetryCount) { + val timestamp = System.currentTimeMillis() + clockOffset + val params = mutableMapOf( + "pubkey" to publicKey, + "messages" to messageHashes, + "timestamp" to timestamp + ) + val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${messageHashes.joinToString(separator = "")}".toByteArray() + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return@retryIfNeeded Promise.ofFail(e) + } + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + getSingleTargetSnode(publicKey).bind { snode -> + invoke(Snode.Method.GetExpiries, snode, params, publicKey) + } + } + } + + fun alterTtl(messageHashes: List, newExpiry: Long, publicKey: String, extend: Boolean = false, shorten: Boolean = false): RawResponsePromise { + return retryIfNeeded(maxRetryCount) { + val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) + ?: return@retryIfNeeded Promise.ofFail( + Exception("Couldn't build signed params for alterTtl request for newExpiry=$newExpiry, extend=$extend, shorten=$shorten") + ) + getSingleTargetSnode(publicKey).bind { snode -> + invoke(Snode.Method.Expire, snode, params, publicKey) + } + } + } + + private fun buildAlterTtlParams( // TODO: in future this will probably need to use the closed group subkeys / admin keys for group swarms + messageHashes: List, + newExpiry: Long, + publicKey: String, + extend: Boolean = false, + shorten: Boolean = false): Map? { + val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + val params = mutableMapOf( + "expiry" to newExpiry, + "messages" to messageHashes, + ) + if (extend) { + params["extend"] = true + } else if (shorten) { + params["shorten"] = true + } + val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else "" + + val signData = "${Snode.Method.Expire.rawValue}$shortenOrExtend$newExpiry${messageHashes.joinToString(separator = "")}".toByteArray() + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["pubkey"] = publicKey + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + + return params } fun getMessages(publicKey: String): MessageListPromise { @@ -483,13 +741,14 @@ object SnodeAPI { retryIfNeeded(maxRetryCount) { getNetworkTime(snode).bind { (_, timestamp) -> val signature = ByteArray(Sign.BYTES) - val verificationData = (Snode.Method.DeleteAll.rawValue + timestamp.toString()).toByteArray() + val verificationData = (Snode.Method.DeleteAll.rawValue + Namespace.ALL + timestamp.toString()).toByteArray() sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) val deleteMessageParams = mapOf( "pubkey" to userPublicKey, "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, "timestamp" to timestamp, - "signature" to Base64.encodeBytes(signature) + "signature" to Base64.encodeBytes(signature), + "namespace" to Namespace.ALL, ) invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey).map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) @@ -502,11 +761,13 @@ object SnodeAPI { } } - fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0): List> { + fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true): List> { val messages = rawResponse["messages"] as? List<*> return if (messages != null) { - updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) - val newRawMessages = removeDuplicates(publicKey, messages, namespace) + if (updateLatestHash) { + updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) + } + val newRawMessages = removeDuplicates(publicKey, messages, namespace, updateStoredHashes) return parseEnvelopes(newRawMessages) } else { listOf() @@ -523,7 +784,7 @@ object SnodeAPI { } } - private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int): List<*> { + private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() val receivedMessageHashValues = originalMessageHashValues.toMutableSet() val result = rawMessages.filter { rawMessage -> @@ -538,7 +799,7 @@ object SnodeAPI { false } } - if (originalMessageHashValues != receivedMessageHashValues) { + if (originalMessageHashValues != receivedMessageHashValues && updateStoredHashes) { database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) } return result @@ -575,11 +836,11 @@ object SnodeAPI { Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") false } else { - val hashes = json["deleted"] as List // Hashes of deleted messages + val hashes = (json["deleted"] as Map>).flatMap { (_, hashes) -> hashes }.sorted() // Hashes of deleted messages val signature = json["signature"] as String val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - val message = (userPublicKey + timestamp.toString() + hashes.fold("") { a, v -> a + v }).toByteArray() + val message = (userPublicKey + timestamp.toString() + hashes.joinToString(separator = "")).toByteArray() sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) } } @@ -635,6 +896,10 @@ object SnodeAPI { Log.d("Loki", "Got a 421 without an associated public key.") } } + 404 -> { + Log.d("Loki", "404, probably no file found") + return Error.Generic + } else -> { handleBadSnode() Log.d("Loki", "Unhandled response code: ${statusCode}.") diff --git a/libsession/src/main/java/org/session/libsession/utilities/Address.kt b/libsession/src/main/java/org/session/libsession/utilities/Address.kt index 7b774602e1..c8cd11d4b6 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Address.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Address.kt @@ -5,11 +5,11 @@ import android.os.Parcel import android.os.Parcelable import android.util.Pair import androidx.annotation.VisibleForTesting -import org.session.libsession.utilities.DelimiterUtil -import org.session.libsession.utilities.GroupUtil -import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Util -import java.util.* +import org.session.libsignal.utilities.guava.Optional +import java.util.Collections +import java.util.LinkedList import java.util.concurrent.atomic.AtomicReference import java.util.regex.Matcher import java.util.regex.Pattern @@ -27,6 +27,8 @@ class Address private constructor(address: String) : Parcelable, Comparable + fun persist(forConfigObject: ConfigBase, timestamp: Long) + + fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean + fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean +} + +interface ConfigFactoryUpdateListener { + fun notifyUpdates(forConfigObject: ConfigBase) +} \ No newline at end of file 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 b850baa253..27b6b244ba 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt @@ -4,7 +4,9 @@ import okhttp3.HttpUrl import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log -import java.io.* +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream object DownloadUtilities { @@ -14,7 +16,7 @@ object DownloadUtilities { @JvmStatic fun downloadFile(destination: File, url: String) { val outputStream = FileOutputStream(destination) // Throws - var remainingAttempts = 4 + var remainingAttempts = 2 var exception: Exception? = null while (remainingAttempts > 0) { remainingAttempts -= 1 diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt index 3458e06eb6..bfab2585de 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt @@ -1,9 +1,9 @@ package org.session.libsession.utilities +import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.Hex import java.io.IOException -import kotlin.jvm.Throws object GroupUtil { const val CLOSED_GROUP_PREFIX = "__textsecure_group__!" @@ -97,4 +97,28 @@ object GroupUtil { fun doubleDecodeGroupID(groupID: String): ByteArray { return getDecodedGroupIDAsData(getDecodedGroupID(groupID)) } + + @JvmStatic + @Throws(IOException::class) + fun doubleDecodeGroupId(groupID: String): String { + return Hex.toStringCondensed(getDecodedGroupIDAsData(getDecodedGroupID(groupID))) + } + + fun createConfigMemberMap( + members: Collection, + admins: Collection + ): Map { + // Start with admins + val memberMap = admins.associate { + it to true + }.toMutableMap() + + // Add the remaining members (there may be duplicates, so only add ones that aren't already in there from admins) + for (member in members) { + if (!memberMap.contains(member)) { + memberMap[member] = false + } + } + return memberMap + } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java index 9e3842fc67..4550965ae7 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java +++ b/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java @@ -1,23 +1,24 @@ package org.session.libsession.utilities; import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.session.libsignal.utilities.Base64; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; import java.io.IOException; public class ProfileKeyUtil { + public static final int PROFILE_KEY_BYTES = 32; + public static synchronized @NonNull byte[] getProfileKey(@NonNull Context context) { try { String encodedProfileKey = TextSecurePreferences.getProfileKey(context); if (encodedProfileKey == null) { - encodedProfileKey = Util.getSecret(32); + encodedProfileKey = Util.getSecret(PROFILE_KEY_BYTES); TextSecurePreferences.setProfileKey(context, encodedProfileKey); } @@ -36,7 +37,7 @@ public class ProfileKeyUtil { } public static synchronized @NonNull String generateEncodedProfileKey(@NonNull Context context) { - return Util.getSecret(32); + return Util.getSecret(PROFILE_KEY_BYTES); } public static synchronized void setEncodedProfileKey(@NonNull Context context, @Nullable String key) { diff --git a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt index b750b39404..f647cc0f48 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt @@ -1,9 +1,9 @@ package org.session.libsession.utilities import android.content.Context +import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient class SSKEnvironment( @@ -30,10 +30,10 @@ class SSKEnvironment( } fun setNickname(context: Context, recipient: Recipient, nickname: String?) - fun setName(context: Context, recipient: Recipient, name: String) - fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) - fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) + fun setName(context: Context, recipient: Recipient, name: String?) + fun setProfilePicture(context: Context, recipient: Recipient, profilePictureURL: String?, profileKey: ByteArray?) fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) + fun contactUpdatedInternal(contact: Contact): String? } interface MessageExpirationManagerProtocol { diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 807c40b43b..d6ed963735 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -12,7 +12,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.session.libsession.BuildConfig import org.session.libsession.R import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED @@ -103,6 +102,8 @@ interface TextSecurePreferences { fun setUpdateApkDigest(value: String?) fun getUpdateApkDigest(): String? fun getLocalNumber(): String? + fun getHasLegacyConfig(): Boolean + fun setHasLegacyConfig(newValue: Boolean) fun setLocalNumber(localNumber: String) fun removeLocalNumber() fun isEnterSendsEnabled(): Boolean @@ -178,6 +179,7 @@ interface TextSecurePreferences { fun setThemeStyle(themeStyle: String) fun setFollowSystemSettings(followSystemSettings: Boolean) fun autoplayAudioMessages(): Boolean + fun hasForcedNewConfig(): Boolean fun hasPreference(key: String): Boolean fun clearAll() @@ -264,6 +266,10 @@ interface TextSecurePreferences { const val AUTOPLAY_AUDIO_MESSAGES = "pref_autoplay_audio" const val FINGERPRINT_KEY_GENERATED = "fingerprint_key_generated" const val SELECTED_ACCENT_COLOR = "selected_accent_color" + + const val HAS_RECEIVED_LEGACY_CONFIG = "has_received_legacy_config" + const val HAS_FORCED_NEW_CONFIG = "has_forced_new_config" + const val GREEN_ACCENT = "accent_green" const val BLUE_ACCENT = "accent_blue" const val PURPLE_ACCENT = "accent_purple" @@ -625,6 +631,17 @@ interface TextSecurePreferences { return getStringPreference(context, LOCAL_NUMBER_PREF, null) } + @JvmStatic + fun getHasLegacyConfig(context: Context): Boolean { + return getBooleanPreference(context, HAS_RECEIVED_LEGACY_CONFIG, false) + } + + @JvmStatic + fun setHasLegacyConfig(context: Context, newValue: Boolean) { + setBooleanPreference(context, HAS_RECEIVED_LEGACY_CONFIG, newValue) + _events.tryEmit(HAS_RECEIVED_LEGACY_CONFIG) + } + fun setLocalNumber(context: Context, localNumber: String) { setStringPreference(context, LOCAL_NUMBER_PREF, localNumber.toLowerCase()) } @@ -795,6 +812,11 @@ interface TextSecurePreferences { setIntegerPreference(context, NOTIFICATION_MESSAGES_CHANNEL_VERSION, version) } + @JvmStatic + fun hasForcedNewConfig(context: Context): Boolean { + return getBooleanPreference(context, HAS_FORCED_NEW_CONFIG, false) + } + @JvmStatic fun getBooleanPreference(context: Context, key: String?, defaultValue: Boolean): Boolean { return getDefaultSharedPreferences(context).getBoolean(key, defaultValue) @@ -1279,6 +1301,15 @@ class AppTextSecurePreferences @Inject constructor( return getStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, null) } + override fun getHasLegacyConfig(): Boolean { + return getBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, false) + } + + override fun setHasLegacyConfig(newValue: Boolean) { + setBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, newValue) + TextSecurePreferences._events.tryEmit(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG) + } + override fun setLocalNumber(localNumber: String) { setStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, localNumber.toLowerCase()) } @@ -1422,6 +1453,9 @@ class AppTextSecurePreferences @Inject constructor( setIntegerPreference(TextSecurePreferences.NOTIFICATION_MESSAGES_CHANNEL_VERSION, version) } + override fun hasForcedNewConfig(): Boolean = + getBooleanPreference(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, false) + override fun getBooleanPreference(key: String?, defaultValue: Boolean): Boolean { return getDefaultSharedPreferences(context).getBoolean(key, defaultValue) } diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index a7fa75dd2b..e2d193a934 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -99,6 +99,7 @@ public class Recipient implements RecipientModifiedListener { private boolean profileSharing; private String notificationChannel; private boolean forceSmsSelection; + private String wrapperHash; private @NonNull UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.ENABLED; @@ -279,6 +280,7 @@ public class Recipient implements RecipientModifiedListener { this.profileSharing = details.profileSharing; this.unidentifiedAccessMode = details.unidentifiedAccessMode; this.forceSmsSelection = details.forceSmsSelection; + this.wrapperHash = details.wrapperHash; this.participants.addAll(details.participants); this.resolving = false; @@ -325,7 +327,7 @@ public class Recipient implements RecipientModifiedListener { return contact.displayName(Contact.ContactContext.REGULAR); } else { Contact contact = storage.getContactWithSessionID(sessionID); - if (contact == null) { return sessionID; } + if (contact == null) { return null; } return contact.displayName(Contact.ContactContext.REGULAR); } } @@ -440,6 +442,10 @@ public class Recipient implements RecipientModifiedListener { return address.isOpenGroup(); } + public boolean isOpenGroupOutboxRecipient() { + return address.isOpenGroupOutbox(); + } + public boolean isOpenGroupInboxRecipient() { return address.isOpenGroupInbox(); } @@ -483,7 +489,13 @@ public class Recipient implements RecipientModifiedListener { public synchronized String toShortString() { String name = getName(); - return (name != null ? name : address.serialize()); + if (name != null) return name; + String sessionId = address.serialize(); + if (sessionId.length() < 4) return sessionId; // so substrings don't throw out of bounds exceptions + int takeAmount = 4; + String start = sessionId.substring(0, takeAmount); + String end = sessionId.substring(sessionId.length()-takeAmount); + return start+"..."+end; } public synchronized @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted) { @@ -717,6 +729,14 @@ public class Recipient implements RecipientModifiedListener { return unidentifiedAccessMode; } + public String getWrapperHash() { + return wrapperHash; + } + + public void setWrapperHash(String wrapperHash) { + this.wrapperHash = wrapperHash; + } + public void setUnidentifiedAccessMode(@NonNull UnidentifiedAccessMode unidentifiedAccessMode) { synchronized (this) { this.unidentifiedAccessMode = unidentifiedAccessMode; @@ -739,12 +759,12 @@ public class Recipient implements RecipientModifiedListener { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Recipient recipient = (Recipient) o; - return resolving == recipient.resolving && mutedUntil == recipient.mutedUntil && notifyType == recipient.notifyType && blocked == recipient.blocked && approved == recipient.approved && approvedMe == recipient.approvedMe && expireMessages == recipient.expireMessages && address.equals(recipient.address) && Objects.equals(name, recipient.name) && Objects.equals(customLabel, recipient.customLabel) && Objects.equals(groupAvatarId, recipient.groupAvatarId) && Arrays.equals(profileKey, recipient.profileKey) && Objects.equals(profileName, recipient.profileName) && Objects.equals(profileAvatar, recipient.profileAvatar); + return resolving == recipient.resolving && mutedUntil == recipient.mutedUntil && notifyType == recipient.notifyType && blocked == recipient.blocked && approved == recipient.approved && approvedMe == recipient.approvedMe && expireMessages == recipient.expireMessages && address.equals(recipient.address) && Objects.equals(name, recipient.name) && Objects.equals(customLabel, recipient.customLabel) && Objects.equals(groupAvatarId, recipient.groupAvatarId) && Arrays.equals(profileKey, recipient.profileKey) && Objects.equals(profileName, recipient.profileName) && Objects.equals(profileAvatar, recipient.profileAvatar) && Objects.equals(wrapperHash, recipient.wrapperHash); } @Override public int hashCode() { - int result = Objects.hash(address, name, customLabel, resolving, groupAvatarId, mutedUntil, notifyType, blocked, approved, approvedMe, expireMessages, profileName, profileAvatar); + int result = Objects.hash(address, name, customLabel, resolving, groupAvatarId, mutedUntil, notifyType, blocked, approved, approvedMe, expireMessages, profileName, profileAvatar, wrapperHash); result = 31 * result + Arrays.hashCode(profileKey); return result; } @@ -848,6 +868,7 @@ public class Recipient implements RecipientModifiedListener { private final String notificationChannel; private final UnidentifiedAccessMode unidentifiedAccessMode; private final boolean forceSmsSelection; + private final String wrapperHash; public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, long muteUntil, int notifyType, @@ -869,7 +890,8 @@ public class Recipient implements RecipientModifiedListener { boolean profileSharing, @Nullable String notificationChannel, @NonNull UnidentifiedAccessMode unidentifiedAccessMode, - boolean forceSmsSelection) + boolean forceSmsSelection, + String wrapperHash) { this.blocked = blocked; this.approved = approved; @@ -895,6 +917,7 @@ public class Recipient implements RecipientModifiedListener { this.notificationChannel = notificationChannel; this.unidentifiedAccessMode = unidentifiedAccessMode; this.forceSmsSelection = forceSmsSelection; + this.wrapperHash = wrapperHash; } public @Nullable MaterialColor getColor() { @@ -992,6 +1015,11 @@ public class Recipient implements RecipientModifiedListener { public boolean isForceSmsSelection() { return forceSmsSelection; } + + public String getWrapperHash() { + return wrapperHash; + } + } diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java index 03c225e207..75ebd837b6 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java @@ -177,6 +177,7 @@ class RecipientProvider { @Nullable final String notificationChannel; @NonNull final UnidentifiedAccessMode unidentifiedAccessMode; final boolean forceSmsSelection; + final String wrapperHash; RecipientDetails(@Nullable String name, @Nullable Long groupAvatarId, boolean systemContact, boolean isLocalNumber, @Nullable RecipientSettings settings, @@ -209,6 +210,7 @@ class RecipientProvider { this.notificationChannel = settings != null ? settings.getNotificationChannel() : null; this.unidentifiedAccessMode = settings != null ? settings.getUnidentifiedAccessMode() : UnidentifiedAccessMode.DISABLED; this.forceSmsSelection = settings != null && settings.isForceSmsSelection(); + this.wrapperHash = settings != null ? settings.getWrapperHash() : null; if (name == null && settings != null) this.name = settings.getSystemDisplayName(); else this.name = name; diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 59e987c54f..64fab0950e 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -220,18 +220,6 @@ - - - - - - - - - - - - diff --git a/libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt b/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt similarity index 98% rename from libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt rename to libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt index 38a244699d..64d1c21fb4 100644 --- a/libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt +++ b/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt @@ -1,9 +1,9 @@ package org.session.libsession.utilities +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* -class OpenGroupUrlParserTest { +class CommunityUrlParserTest { @Test fun parseUrlTest() { diff --git a/libsignal/protobuf/SignalService.proto b/libsignal/protobuf/SignalService.proto index 50c5218334..68dd35ce61 100644 --- a/libsignal/protobuf/SignalService.proto +++ b/libsignal/protobuf/SignalService.proto @@ -51,6 +51,7 @@ message Content { optional DataExtractionNotification dataExtractionNotification = 8; optional UnsendRequest unsendRequest = 9; optional MessageRequestResponse messageRequestResponse = 10; + optional SharedConfigMessage sharedConfigMessage = 11; } message KeyPair { @@ -238,6 +239,25 @@ message MessageRequestResponse { optional DataMessage.LokiProfile profile = 3; } +message SharedConfigMessage { + enum Kind { + USER_PROFILE = 1; + CONTACTS = 2; + CONVO_INFO_VOLATILE = 3; + GROUPS = 4; + CLOSED_GROUP_INFO = 5; + CLOSED_GROUP_MEMBERS = 6; + ENCRYPTION_KEYS = 7; + } + + // @required + required Kind kind = 1; + // @required + required int64 seqno = 2; + // @required + required bytes data = 3; +} + message ReceiptMessage { enum Type { diff --git a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java index 7c44087f83..8e26b05d92 100644 --- a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java +++ b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java @@ -2468,6 +2468,20 @@ public final class SignalServiceProtos { * optional .signalservice.MessageRequestResponse messageRequestResponse = 10; */ org.session.libsignal.protos.SignalServiceProtos.MessageRequestResponseOrBuilder getMessageRequestResponseOrBuilder(); + + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + boolean hasSharedConfigMessage(); + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage(); + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder(); } /** * Protobuf type {@code signalservice.Content} @@ -2624,6 +2638,19 @@ public final class SignalServiceProtos { bitField0_ |= 0x00000080; break; } + case 90: { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder subBuilder = null; + if (((bitField0_ & 0x00000100) == 0x00000100)) { + subBuilder = sharedConfigMessage_.toBuilder(); + } + sharedConfigMessage_ = input.readMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.PARSER, extensionRegistry); + if (subBuilder != null) { + subBuilder.mergeFrom(sharedConfigMessage_); + sharedConfigMessage_ = subBuilder.buildPartial(); + } + bitField0_ |= 0x00000100; + break; + } } } } catch (com.google.protobuf.InvalidProtocolBufferException e) { @@ -2840,6 +2867,28 @@ public final class SignalServiceProtos { return messageRequestResponse_; } + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + public static final int SHAREDCONFIGMESSAGE_FIELD_NUMBER = 11; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage sharedConfigMessage_; + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public boolean hasSharedConfigMessage() { + return ((bitField0_ & 0x00000100) == 0x00000100); + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage() { + return sharedConfigMessage_; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder() { + return sharedConfigMessage_; + } + private void initFields() { dataMessage_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.getDefaultInstance(); callMessage_ = org.session.libsignal.protos.SignalServiceProtos.CallMessage.getDefaultInstance(); @@ -2849,6 +2898,7 @@ public final class SignalServiceProtos { dataExtractionNotification_ = org.session.libsignal.protos.SignalServiceProtos.DataExtractionNotification.getDefaultInstance(); unsendRequest_ = org.session.libsignal.protos.SignalServiceProtos.UnsendRequest.getDefaultInstance(); messageRequestResponse_ = org.session.libsignal.protos.SignalServiceProtos.MessageRequestResponse.getDefaultInstance(); + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); } private byte memoizedIsInitialized = -1; public final boolean isInitialized() { @@ -2903,6 +2953,12 @@ public final class SignalServiceProtos { return false; } } + if (hasSharedConfigMessage()) { + if (!getSharedConfigMessage().isInitialized()) { + memoizedIsInitialized = 0; + return false; + } + } memoizedIsInitialized = 1; return true; } @@ -2934,6 +2990,9 @@ public final class SignalServiceProtos { if (((bitField0_ & 0x00000080) == 0x00000080)) { output.writeMessage(10, messageRequestResponse_); } + if (((bitField0_ & 0x00000100) == 0x00000100)) { + output.writeMessage(11, sharedConfigMessage_); + } getUnknownFields().writeTo(output); } @@ -2975,6 +3034,10 @@ public final class SignalServiceProtos { size += com.google.protobuf.CodedOutputStream .computeMessageSize(10, messageRequestResponse_); } + if (((bitField0_ & 0x00000100) == 0x00000100)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(11, sharedConfigMessage_); + } size += getUnknownFields().getSerializedSize(); memoizedSerializedSize = size; return size; @@ -3091,6 +3154,7 @@ public final class SignalServiceProtos { getDataExtractionNotificationFieldBuilder(); getUnsendRequestFieldBuilder(); getMessageRequestResponseFieldBuilder(); + getSharedConfigMessageFieldBuilder(); } } private static Builder create() { @@ -3147,6 +3211,12 @@ public final class SignalServiceProtos { messageRequestResponseBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000080); + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + } else { + sharedConfigMessageBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000100); return this; } @@ -3239,6 +3309,14 @@ public final class SignalServiceProtos { } else { result.messageRequestResponse_ = messageRequestResponseBuilder_.build(); } + if (((from_bitField0_ & 0x00000100) == 0x00000100)) { + to_bitField0_ |= 0x00000100; + } + if (sharedConfigMessageBuilder_ == null) { + result.sharedConfigMessage_ = sharedConfigMessage_; + } else { + result.sharedConfigMessage_ = sharedConfigMessageBuilder_.build(); + } result.bitField0_ = to_bitField0_; onBuilt(); return result; @@ -3279,6 +3357,9 @@ public final class SignalServiceProtos { if (other.hasMessageRequestResponse()) { mergeMessageRequestResponse(other.getMessageRequestResponse()); } + if (other.hasSharedConfigMessage()) { + mergeSharedConfigMessage(other.getSharedConfigMessage()); + } this.mergeUnknownFields(other.getUnknownFields()); return this; } @@ -3332,6 +3413,12 @@ public final class SignalServiceProtos { return false; } } + if (hasSharedConfigMessage()) { + if (!getSharedConfigMessage().isInitialized()) { + + return false; + } + } return true; } @@ -4290,6 +4377,123 @@ public final class SignalServiceProtos { return messageRequestResponseBuilder_; } + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder> sharedConfigMessageBuilder_; + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public boolean hasSharedConfigMessage() { + return ((bitField0_ & 0x00000100) == 0x00000100); + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage() { + if (sharedConfigMessageBuilder_ == null) { + return sharedConfigMessage_; + } else { + return sharedConfigMessageBuilder_.getMessage(); + } + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder setSharedConfigMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage value) { + if (sharedConfigMessageBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + sharedConfigMessage_ = value; + onChanged(); + } else { + sharedConfigMessageBuilder_.setMessage(value); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder setSharedConfigMessage( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder builderForValue) { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = builderForValue.build(); + onChanged(); + } else { + sharedConfigMessageBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder mergeSharedConfigMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage value) { + if (sharedConfigMessageBuilder_ == null) { + if (((bitField0_ & 0x00000100) == 0x00000100) && + sharedConfigMessage_ != org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance()) { + sharedConfigMessage_ = + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.newBuilder(sharedConfigMessage_).mergeFrom(value).buildPartial(); + } else { + sharedConfigMessage_ = value; + } + onChanged(); + } else { + sharedConfigMessageBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public Builder clearSharedConfigMessage() { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + onChanged(); + } else { + sharedConfigMessageBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000100); + return this; + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder getSharedConfigMessageBuilder() { + bitField0_ |= 0x00000100; + onChanged(); + return getSharedConfigMessageFieldBuilder().getBuilder(); + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder() { + if (sharedConfigMessageBuilder_ != null) { + return sharedConfigMessageBuilder_.getMessageOrBuilder(); + } else { + return sharedConfigMessage_; + } + } + /** + * optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + */ + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder> + getSharedConfigMessageFieldBuilder() { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessageBuilder_ = new com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder>( + sharedConfigMessage_, + getParentForChildren(), + isClean()); + sharedConfigMessage_ = null; + } + return sharedConfigMessageBuilder_; + } + // @@protoc_insertion_point(builder_scope:signalservice.Content) } @@ -22196,6 +22400,823 @@ public final class SignalServiceProtos { // @@protoc_insertion_point(class_scope:signalservice.MessageRequestResponse) } + public interface SharedConfigMessageOrBuilder + extends com.google.protobuf.MessageOrBuilder { + + // required .signalservice.SharedConfigMessage.Kind kind = 1; + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + boolean hasKind(); + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind(); + + // required int64 seqno = 2; + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + boolean hasSeqno(); + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + long getSeqno(); + + // required bytes data = 3; + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + boolean hasData(); + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + com.google.protobuf.ByteString getData(); + } + /** + * Protobuf type {@code signalservice.SharedConfigMessage} + */ + public static final class SharedConfigMessage extends + com.google.protobuf.GeneratedMessage + implements SharedConfigMessageOrBuilder { + // Use SharedConfigMessage.newBuilder() to construct. + private SharedConfigMessage(com.google.protobuf.GeneratedMessage.Builder builder) { + super(builder); + this.unknownFields = builder.getUnknownFields(); + } + private SharedConfigMessage(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } + + private static final SharedConfigMessage defaultInstance; + public static SharedConfigMessage getDefaultInstance() { + return defaultInstance; + } + + public SharedConfigMessage getDefaultInstanceForType() { + return defaultInstance; + } + + private final com.google.protobuf.UnknownFieldSet unknownFields; + @java.lang.Override + public final com.google.protobuf.UnknownFieldSet + getUnknownFields() { + return this.unknownFields; + } + private SharedConfigMessage( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + initFields(); + int mutable_bitField0_ = 0; + com.google.protobuf.UnknownFieldSet.Builder unknownFields = + com.google.protobuf.UnknownFieldSet.newBuilder(); + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + default: { + if (!parseUnknownField(input, unknownFields, + extensionRegistry, tag)) { + done = true; + } + break; + } + case 8: { + int rawValue = input.readEnum(); + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind value = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.valueOf(rawValue); + if (value == null) { + unknownFields.mergeVarintField(1, rawValue); + } else { + bitField0_ |= 0x00000001; + kind_ = value; + } + break; + } + case 16: { + bitField0_ |= 0x00000002; + seqno_ = input.readInt64(); + break; + } + case 26: { + bitField0_ |= 0x00000004; + data_ = input.readBytes(); + break; + } + } + } + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(this); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException( + e.getMessage()).setUnfinishedMessage(this); + } finally { + this.unknownFields = unknownFields.build(); + makeExtensionsImmutable(); + } + } + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.class, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder.class); + } + + public static com.google.protobuf.Parser PARSER = + new com.google.protobuf.AbstractParser() { + public SharedConfigMessage parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return new SharedConfigMessage(input, extensionRegistry); + } + }; + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + /** + * Protobuf enum {@code signalservice.SharedConfigMessage.Kind} + */ + public enum Kind + implements com.google.protobuf.ProtocolMessageEnum { + /** + * USER_PROFILE = 1; + */ + USER_PROFILE(0, 1), + /** + * CONTACTS = 2; + */ + CONTACTS(1, 2), + /** + * CONVO_INFO_VOLATILE = 3; + */ + CONVO_INFO_VOLATILE(2, 3), + /** + * GROUPS = 4; + */ + GROUPS(3, 4), + /** + * CLOSED_GROUP_INFO = 5; + */ + CLOSED_GROUP_INFO(4, 5), + /** + * CLOSED_GROUP_MEMBERS = 6; + */ + CLOSED_GROUP_MEMBERS(5, 6), + /** + * ENCRYPTION_KEYS = 7; + */ + ENCRYPTION_KEYS(6, 7), + ; + + /** + * USER_PROFILE = 1; + */ + public static final int USER_PROFILE_VALUE = 1; + /** + * CONTACTS = 2; + */ + public static final int CONTACTS_VALUE = 2; + /** + * CONVO_INFO_VOLATILE = 3; + */ + public static final int CONVO_INFO_VOLATILE_VALUE = 3; + /** + * GROUPS = 4; + */ + public static final int GROUPS_VALUE = 4; + /** + * CLOSED_GROUP_INFO = 5; + */ + public static final int CLOSED_GROUP_INFO_VALUE = 5; + /** + * CLOSED_GROUP_MEMBERS = 6; + */ + public static final int CLOSED_GROUP_MEMBERS_VALUE = 6; + /** + * ENCRYPTION_KEYS = 7; + */ + public static final int ENCRYPTION_KEYS_VALUE = 7; + + + public final int getNumber() { return value; } + + public static Kind valueOf(int value) { + switch (value) { + case 1: return USER_PROFILE; + case 2: return CONTACTS; + case 3: return CONVO_INFO_VOLATILE; + case 4: return GROUPS; + case 5: return CLOSED_GROUP_INFO; + case 6: return CLOSED_GROUP_MEMBERS; + case 7: return ENCRYPTION_KEYS; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap + internalGetValueMap() { + return internalValueMap; + } + private static com.google.protobuf.Internal.EnumLiteMap + internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap() { + public Kind findValueByNumber(int number) { + return Kind.valueOf(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + return getDescriptor().getValues().get(index); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDescriptor().getEnumTypes().get(0); + } + + private static final Kind[] VALUES = values(); + + public static Kind valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + return VALUES[desc.getIndex()]; + } + + private final int index; + private final int value; + + private Kind(int index, int value) { + this.index = index; + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:signalservice.SharedConfigMessage.Kind) + } + + private int bitField0_; + // required .signalservice.SharedConfigMessage.Kind kind = 1; + public static final int KIND_FIELD_NUMBER = 1; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind kind_; + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + public boolean hasKind() { + return ((bitField0_ & 0x00000001) == 0x00000001); + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+     * @required
+     * 
+ */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind() { + return kind_; + } + + // required int64 seqno = 2; + public static final int SEQNO_FIELD_NUMBER = 2; + private long seqno_; + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + public boolean hasSeqno() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * required int64 seqno = 2; + * + *
+     * @required
+     * 
+ */ + public long getSeqno() { + return seqno_; + } + + // required bytes data = 3; + public static final int DATA_FIELD_NUMBER = 3; + private com.google.protobuf.ByteString data_; + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + public boolean hasData() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * required bytes data = 3; + * + *
+     * @required
+     * 
+ */ + public com.google.protobuf.ByteString getData() { + return data_; + } + + private void initFields() { + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + seqno_ = 0L; + data_ = com.google.protobuf.ByteString.EMPTY; + } + private byte memoizedIsInitialized = -1; + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized != -1) return isInitialized == 1; + + if (!hasKind()) { + memoizedIsInitialized = 0; + return false; + } + if (!hasSeqno()) { + memoizedIsInitialized = 0; + return false; + } + if (!hasData()) { + memoizedIsInitialized = 0; + return false; + } + memoizedIsInitialized = 1; + return true; + } + + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + getSerializedSize(); + if (((bitField0_ & 0x00000001) == 0x00000001)) { + output.writeEnum(1, kind_.getNumber()); + } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + output.writeInt64(2, seqno_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + output.writeBytes(3, data_); + } + getUnknownFields().writeTo(output); + } + + private int memoizedSerializedSize = -1; + public int getSerializedSize() { + int size = memoizedSerializedSize; + if (size != -1) return size; + + size = 0; + if (((bitField0_ & 0x00000001) == 0x00000001)) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(1, kind_.getNumber()); + } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(2, seqno_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + size += com.google.protobuf.CodedOutputStream + .computeBytesSize(3, data_); + } + size += getUnknownFields().getSerializedSize(); + memoizedSerializedSize = size; + return size; + } + + private static final long serialVersionUID = 0L; + @java.lang.Override + protected java.lang.Object writeReplace() + throws java.io.ObjectStreamException { + return super.writeReplace(); + } + + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom(java.io.InputStream input) + throws java.io.IOException { + return PARSER.parseFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseFrom(input, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return PARSER.parseDelimitedFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseDelimitedFrom(input, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return PARSER.parseFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseFrom(input, extensionRegistry); + } + + public static Builder newBuilder() { return Builder.create(); } + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage prototype) { + return newBuilder().mergeFrom(prototype); + } + public Builder toBuilder() { return newBuilder(this); } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code signalservice.SharedConfigMessage} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessage.Builder + implements org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.class, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder.class); + } + + // Construct using org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.newBuilder() + private Builder() { + maybeForceBuilderInitialization(); + } + + private Builder( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + super(parent); + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { + } + } + private static Builder create() { + return new Builder(); + } + + public Builder clear() { + super.clear(); + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + bitField0_ = (bitField0_ & ~0x00000001); + seqno_ = 0L; + bitField0_ = (bitField0_ & ~0x00000002); + data_ = com.google.protobuf.ByteString.EMPTY; + bitField0_ = (bitField0_ & ~0x00000004); + return this; + } + + public Builder clone() { + return create().mergeFrom(buildPartial()); + } + + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getDefaultInstanceForType() { + return org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage build() { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage buildPartial() { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage result = new org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage(this); + int from_bitField0_ = bitField0_; + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000001) == 0x00000001)) { + to_bitField0_ |= 0x00000001; + } + result.kind_ = kind_; + if (((from_bitField0_ & 0x00000002) == 0x00000002)) { + to_bitField0_ |= 0x00000002; + } + result.seqno_ = seqno_; + if (((from_bitField0_ & 0x00000004) == 0x00000004)) { + to_bitField0_ |= 0x00000004; + } + result.data_ = data_; + result.bitField0_ = to_bitField0_; + onBuilt(); + return result; + } + + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage) { + return mergeFrom((org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage other) { + if (other == org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance()) return this; + if (other.hasKind()) { + setKind(other.getKind()); + } + if (other.hasSeqno()) { + setSeqno(other.getSeqno()); + } + if (other.hasData()) { + setData(other.getData()); + } + this.mergeUnknownFields(other.getUnknownFields()); + return this; + } + + public final boolean isInitialized() { + if (!hasKind()) { + + return false; + } + if (!hasSeqno()) { + + return false; + } + if (!hasData()) { + + return false; + } + return true; + } + + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parsedMessage = null; + try { + parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + parsedMessage = (org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage) e.getUnfinishedMessage(); + throw e; + } finally { + if (parsedMessage != null) { + mergeFrom(parsedMessage); + } + } + return this; + } + private int bitField0_; + + // required .signalservice.SharedConfigMessage.Kind kind = 1; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public boolean hasKind() { + return ((bitField0_ & 0x00000001) == 0x00000001); + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind() { + return kind_; + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public Builder setKind(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000001; + kind_ = value; + onChanged(); + return this; + } + /** + * required .signalservice.SharedConfigMessage.Kind kind = 1; + * + *
+       * @required
+       * 
+ */ + public Builder clearKind() { + bitField0_ = (bitField0_ & ~0x00000001); + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + onChanged(); + return this; + } + + // required int64 seqno = 2; + private long seqno_ ; + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public boolean hasSeqno() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public long getSeqno() { + return seqno_; + } + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public Builder setSeqno(long value) { + bitField0_ |= 0x00000002; + seqno_ = value; + onChanged(); + return this; + } + /** + * required int64 seqno = 2; + * + *
+       * @required
+       * 
+ */ + public Builder clearSeqno() { + bitField0_ = (bitField0_ & ~0x00000002); + seqno_ = 0L; + onChanged(); + return this; + } + + // required bytes data = 3; + private com.google.protobuf.ByteString data_ = com.google.protobuf.ByteString.EMPTY; + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public boolean hasData() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public com.google.protobuf.ByteString getData() { + return data_; + } + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public Builder setData(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000004; + data_ = value; + onChanged(); + return this; + } + /** + * required bytes data = 3; + * + *
+       * @required
+       * 
+ */ + public Builder clearData() { + bitField0_ = (bitField0_ & ~0x00000004); + data_ = getDefaultInstance().getData(); + onChanged(); + return this; + } + + // @@protoc_insertion_point(builder_scope:signalservice.SharedConfigMessage) + } + + static { + defaultInstance = new SharedConfigMessage(true); + defaultInstance.initFields(); + } + + // @@protoc_insertion_point(class_scope:signalservice.SharedConfigMessage) + } + public interface ReceiptMessageOrBuilder extends com.google.protobuf.MessageOrBuilder { @@ -26081,6 +27102,11 @@ public final class SignalServiceProtos { private static com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_signalservice_MessageRequestResponse_fieldAccessorTable; + private static com.google.protobuf.Descriptors.Descriptor + internal_static_signalservice_SharedConfigMessage_descriptor; + private static + com.google.protobuf.GeneratedMessage.FieldAccessorTable + internal_static_signalservice_SharedConfigMessage_fieldAccessorTable; private static com.google.protobuf.Descriptors.Descriptor internal_static_signalservice_ReceiptMessage_descriptor; private static @@ -26115,7 +27141,7 @@ public final class SignalServiceProtos { "\002(\004\0223\n\006action\030\002 \002(\0162#.signalservice.Typi" + "ngMessage.Action\"\"\n\006Action\022\013\n\007STARTED\020\000\022" + "\013\n\007STOPPED\020\001\"2\n\rUnsendRequest\022\021\n\ttimesta", - "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\345\003\n\007Content\022/\n\013" + + "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\246\004\n\007Content\022/\n\013" + "dataMessage\030\001 \001(\0132\032.signalservice.DataMe" + "ssage\022/\n\013callMessage\030\003 \001(\0132\032.signalservi" + "ce.CallMessage\0225\n\016receiptMessage\030\005 \001(\0132\035" + @@ -26127,96 +27153,104 @@ public final class SignalServiceProtos { "e.DataExtractionNotification\0223\n\runsendRe", "quest\030\t \001(\0132\034.signalservice.UnsendReques" + "t\022E\n\026messageRequestResponse\030\n \001(\0132%.sign" + - "alservice.MessageRequestResponse\"0\n\007KeyP" + - "air\022\021\n\tpublicKey\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002" + - "(\014\"\226\001\n\032DataExtractionNotification\022<\n\004typ" + - "e\030\001 \002(\0162..signalservice.DataExtractionNo" + - "tification.Type\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Ty" + - "pe\022\016\n\nSCREENSHOT\020\001\022\017\n\013MEDIA_SAVED\020\002\"\361\r\n\013" + - "DataMessage\022\014\n\004body\030\001 \001(\t\0225\n\013attachments" + - "\030\002 \003(\0132 .signalservice.AttachmentPointer", - "\022*\n\005group\030\003 \001(\0132\033.signalservice.GroupCon" + - "text\022\r\n\005flags\030\004 \001(\r\022\023\n\013expireTimer\030\005 \001(\r" + - "\022\022\n\nprofileKey\030\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022" + - "/\n\005quote\030\010 \001(\0132 .signalservice.DataMessa" + - "ge.Quote\0223\n\007preview\030\n \003(\0132\".signalservic" + - "e.DataMessage.Preview\0225\n\010reaction\030\013 \001(\0132" + - "#.signalservice.DataMessage.Reaction\0227\n\007" + - "profile\030e \001(\0132&.signalservice.DataMessag" + - "e.LokiProfile\022K\n\023openGroupInvitation\030f \001" + - "(\0132..signalservice.DataMessage.OpenGroup", - "Invitation\022W\n\031closedGroupControlMessage\030" + - "h \001(\01324.signalservice.DataMessage.Closed" + - "GroupControlMessage\022\022\n\nsyncTarget\030i \001(\t\032" + - "\225\002\n\005Quote\022\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n" + - "\004text\030\003 \001(\t\022F\n\013attachments\030\004 \003(\01321.signa" + - "lservice.DataMessage.Quote.QuotedAttachm" + - "ent\032\231\001\n\020QuotedAttachment\022\023\n\013contentType\030" + - "\001 \001(\t\022\020\n\010fileName\030\002 \001(\t\0223\n\tthumbnail\030\003 \001" + - "(\0132 .signalservice.AttachmentPointer\022\r\n\005" + - "flags\030\004 \001(\r\"\032\n\005Flags\022\021\n\rVOICE_MESSAGE\020\001\032", - "V\n\007Preview\022\013\n\003url\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/" + - "\n\005image\030\003 \001(\0132 .signalservice.Attachment" + - "Pointer\032:\n\013LokiProfile\022\023\n\013displayName\030\001 " + - "\001(\t\022\026\n\016profilePicture\030\002 \001(\t\0320\n\023OpenGroup" + - "Invitation\022\013\n\003url\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003" + - "\n\031ClosedGroupControlMessage\022G\n\004type\030\001 \002(" + - "\01629.signalservice.DataMessage.ClosedGrou" + - "pControlMessage.Type\022\021\n\tpublicKey\030\002 \001(\014\022" + - "\014\n\004name\030\003 \001(\t\0221\n\021encryptionKeyPair\030\004 \001(\013" + - "2\026.signalservice.KeyPair\022\017\n\007members\030\005 \003(", - "\014\022\016\n\006admins\030\006 \003(\014\022U\n\010wrappers\030\007 \003(\0132C.si" + - "gnalservice.DataMessage.ClosedGroupContr" + - "olMessage.KeyPairWrapper\022\027\n\017expirationTi" + - "mer\030\010 \001(\r\032=\n\016KeyPairWrapper\022\021\n\tpublicKey" + - "\030\001 \002(\014\022\030\n\020encryptedKeyPair\030\002 \002(\014\"r\n\004Type" + - "\022\007\n\003NEW\020\001\022\027\n\023ENCRYPTION_KEY_PAIR\020\003\022\017\n\013NA" + - "ME_CHANGE\020\004\022\021\n\rMEMBERS_ADDED\020\005\022\023\n\017MEMBER" + - "S_REMOVED\020\006\022\017\n\013MEMBER_LEFT\020\007\032\222\001\n\010Reactio" + - "n\022\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003" + - " \001(\t\022:\n\006action\030\004 \002(\0162*.signalservice.Dat", - "aMessage.Reaction.Action\"\037\n\006Action\022\t\n\005RE" + - "ACT\020\000\022\n\n\006REMOVE\020\001\"$\n\005Flags\022\033\n\027EXPIRATION" + - "_TIMER_UPDATE\020\002\"\352\001\n\013CallMessage\022-\n\004type\030" + - "\001 \002(\0162\037.signalservice.CallMessage.Type\022\014" + - "\n\004sdps\030\002 \003(\t\022\027\n\017sdpMLineIndexes\030\003 \003(\r\022\017\n" + - "\007sdpMids\030\004 \003(\t\022\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\t" + - "PRE_OFFER\020\006\022\t\n\005OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PR" + - "OVISIONAL_ANSWER\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014" + - "\n\010END_CALL\020\005\"\245\004\n\024ConfigurationMessage\022E\n" + - "\014closedGroups\030\001 \003(\0132/.signalservice.Conf", - "igurationMessage.ClosedGroup\022\022\n\nopenGrou" + - "ps\030\002 \003(\t\022\023\n\013displayName\030\003 \001(\t\022\026\n\016profile" + - "Picture\030\004 \001(\t\022\022\n\nprofileKey\030\005 \001(\014\022=\n\010con" + - "tacts\030\006 \003(\0132+.signalservice.Configuratio" + - "nMessage.Contact\032\233\001\n\013ClosedGroup\022\021\n\tpubl" + - "icKey\030\001 \001(\014\022\014\n\004name\030\002 \001(\t\0221\n\021encryptionK" + - "eyPair\030\003 \001(\0132\026.signalservice.KeyPair\022\017\n\007" + - "members\030\004 \003(\014\022\016\n\006admins\030\005 \003(\014\022\027\n\017expirat" + - "ionTimer\030\006 \001(\r\032\223\001\n\007Contact\022\021\n\tpublicKey\030" + - "\001 \002(\014\022\014\n\004name\030\002 \002(\t\022\026\n\016profilePicture\030\003 ", - "\001(\t\022\022\n\nprofileKey\030\004 \001(\014\022\022\n\nisApproved\030\005 " + - "\001(\010\022\021\n\tisBlocked\030\006 \001(\010\022\024\n\014didApproveMe\030\007" + - " \001(\010\"y\n\026MessageRequestResponse\022\022\n\nisAppr" + - "oved\030\001 \002(\010\022\022\n\nprofileKey\030\002 \001(\014\0227\n\007profil" + - "e\030\003 \001(\0132&.signalservice.DataMessage.Loki" + - "Profile\"u\n\016ReceiptMessage\0220\n\004type\030\001 \002(\0162" + - "\".signalservice.ReceiptMessage.Type\022\021\n\tt" + - "imestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010DELIVERY\020\000\022\010\n\004" + - "READ\020\001\"\354\001\n\021AttachmentPointer\022\n\n\002id\030\001 \002(\006" + - "\022\023\n\013contentType\030\002 \001(\t\022\013\n\003key\030\003 \001(\014\022\014\n\004si", - "ze\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(\014\022\016\n\006digest\030\006 " + - "\001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005flags\030\010 \001(\r\022\r\n\005" + - "width\030\t \001(\r\022\016\n\006height\030\n \001(\r\022\017\n\007caption\030\013" + - " \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags\022\021\n\rVOICE_MESS" + - "AGE\020\001\"\365\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022.\n\004ty" + - "pe\030\002 \001(\0162 .signalservice.GroupContext.Ty" + - "pe\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030\004 \003(\t\0220\n\006ava" + - "tar\030\005 \001(\0132 .signalservice.AttachmentPoin" + - "ter\022\016\n\006admins\030\006 \003(\t\"H\n\004Type\022\013\n\007UNKNOWN\020\000" + - "\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022\010\n\004QUIT\020\003\022\020\n\014R", - "EQUEST_INFO\020\004B3\n\034org.session.libsignal.p" + - "rotosB\023SignalServiceProtos" + "alservice.MessageRequestResponse\022?\n\023shar" + + "edConfigMessage\030\013 \001(\0132\".signalservice.Sh" + + "aredConfigMessage\"0\n\007KeyPair\022\021\n\tpublicKe" + + "y\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002(\014\"\226\001\n\032DataExtr" + + "actionNotification\022<\n\004type\030\001 \002(\0162..signa" + + "lservice.DataExtractionNotification.Type" + + "\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Type\022\016\n\nSCREENSHO" + + "T\020\001\022\017\n\013MEDIA_SAVED\020\002\"\361\r\n\013DataMessage\022\014\n\004", + "body\030\001 \001(\t\0225\n\013attachments\030\002 \003(\0132 .signal" + + "service.AttachmentPointer\022*\n\005group\030\003 \001(\013" + + "2\033.signalservice.GroupContext\022\r\n\005flags\030\004" + + " \001(\r\022\023\n\013expireTimer\030\005 \001(\r\022\022\n\nprofileKey\030" + + "\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022/\n\005quote\030\010 \001(\0132" + + " .signalservice.DataMessage.Quote\0223\n\007pre" + + "view\030\n \003(\0132\".signalservice.DataMessage.P" + + "review\0225\n\010reaction\030\013 \001(\0132#.signalservice" + + ".DataMessage.Reaction\0227\n\007profile\030e \001(\0132&" + + ".signalservice.DataMessage.LokiProfile\022K", + "\n\023openGroupInvitation\030f \001(\0132..signalserv" + + "ice.DataMessage.OpenGroupInvitation\022W\n\031c" + + "losedGroupControlMessage\030h \001(\01324.signals" + + "ervice.DataMessage.ClosedGroupControlMes" + + "sage\022\022\n\nsyncTarget\030i \001(\t\032\225\002\n\005Quote\022\n\n\002id" + + "\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n\004text\030\003 \001(\t\022F\n\013" + + "attachments\030\004 \003(\01321.signalservice.DataMe" + + "ssage.Quote.QuotedAttachment\032\231\001\n\020QuotedA" + + "ttachment\022\023\n\013contentType\030\001 \001(\t\022\020\n\010fileNa" + + "me\030\002 \001(\t\0223\n\tthumbnail\030\003 \001(\0132 .signalserv", + "ice.AttachmentPointer\022\r\n\005flags\030\004 \001(\r\"\032\n\005" + + "Flags\022\021\n\rVOICE_MESSAGE\020\001\032V\n\007Preview\022\013\n\003u" + + "rl\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/\n\005image\030\003 \001(\0132 " + + ".signalservice.AttachmentPointer\032:\n\013Loki" + + "Profile\022\023\n\013displayName\030\001 \001(\t\022\026\n\016profileP" + + "icture\030\002 \001(\t\0320\n\023OpenGroupInvitation\022\013\n\003u" + + "rl\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003\n\031ClosedGroupCo" + + "ntrolMessage\022G\n\004type\030\001 \002(\01629.signalservi" + + "ce.DataMessage.ClosedGroupControlMessage" + + ".Type\022\021\n\tpublicKey\030\002 \001(\014\022\014\n\004name\030\003 \001(\t\0221", + "\n\021encryptionKeyPair\030\004 \001(\0132\026.signalservic" + + "e.KeyPair\022\017\n\007members\030\005 \003(\014\022\016\n\006admins\030\006 \003" + + "(\014\022U\n\010wrappers\030\007 \003(\0132C.signalservice.Dat" + + "aMessage.ClosedGroupControlMessage.KeyPa" + + "irWrapper\022\027\n\017expirationTimer\030\010 \001(\r\032=\n\016Ke" + + "yPairWrapper\022\021\n\tpublicKey\030\001 \002(\014\022\030\n\020encry" + + "ptedKeyPair\030\002 \002(\014\"r\n\004Type\022\007\n\003NEW\020\001\022\027\n\023EN" + + "CRYPTION_KEY_PAIR\020\003\022\017\n\013NAME_CHANGE\020\004\022\021\n\r" + + "MEMBERS_ADDED\020\005\022\023\n\017MEMBERS_REMOVED\020\006\022\017\n\013" + + "MEMBER_LEFT\020\007\032\222\001\n\010Reaction\022\n\n\002id\030\001 \002(\004\022\016", + "\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003 \001(\t\022:\n\006action\030" + + "\004 \002(\0162*.signalservice.DataMessage.Reacti" + + "on.Action\"\037\n\006Action\022\t\n\005REACT\020\000\022\n\n\006REMOVE" + + "\020\001\"$\n\005Flags\022\033\n\027EXPIRATION_TIMER_UPDATE\020\002" + + "\"\352\001\n\013CallMessage\022-\n\004type\030\001 \002(\0162\037.signals" + + "ervice.CallMessage.Type\022\014\n\004sdps\030\002 \003(\t\022\027\n" + + "\017sdpMLineIndexes\030\003 \003(\r\022\017\n\007sdpMids\030\004 \003(\t\022" + + "\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\tPRE_OFFER\020\006\022\t\n\005" + + "OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PROVISIONAL_ANSWE" + + "R\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014\n\010END_CALL\020\005\"\245\004", + "\n\024ConfigurationMessage\022E\n\014closedGroups\030\001" + + " \003(\0132/.signalservice.ConfigurationMessag" + + "e.ClosedGroup\022\022\n\nopenGroups\030\002 \003(\t\022\023\n\013dis" + + "playName\030\003 \001(\t\022\026\n\016profilePicture\030\004 \001(\t\022\022" + + "\n\nprofileKey\030\005 \001(\014\022=\n\010contacts\030\006 \003(\0132+.s" + + "ignalservice.ConfigurationMessage.Contac" + + "t\032\233\001\n\013ClosedGroup\022\021\n\tpublicKey\030\001 \001(\014\022\014\n\004" + + "name\030\002 \001(\t\0221\n\021encryptionKeyPair\030\003 \001(\0132\026." + + "signalservice.KeyPair\022\017\n\007members\030\004 \003(\014\022\016" + + "\n\006admins\030\005 \003(\014\022\027\n\017expirationTimer\030\006 \001(\r\032", + "\223\001\n\007Contact\022\021\n\tpublicKey\030\001 \002(\014\022\014\n\004name\030\002" + + " \002(\t\022\026\n\016profilePicture\030\003 \001(\t\022\022\n\nprofileK" + + "ey\030\004 \001(\014\022\022\n\nisApproved\030\005 \001(\010\022\021\n\tisBlocke" + + "d\030\006 \001(\010\022\024\n\014didApproveMe\030\007 \001(\010\"y\n\026Message" + + "RequestResponse\022\022\n\nisApproved\030\001 \002(\010\022\022\n\np" + + "rofileKey\030\002 \001(\014\0227\n\007profile\030\003 \001(\0132&.signa" + + "lservice.DataMessage.LokiProfile\"\375\001\n\023Sha" + + "redConfigMessage\0225\n\004kind\030\001 \002(\0162\'.signals" + + "ervice.SharedConfigMessage.Kind\022\r\n\005seqno" + + "\030\002 \002(\003\022\014\n\004data\030\003 \002(\014\"\221\001\n\004Kind\022\020\n\014USER_PR", + "OFILE\020\001\022\014\n\010CONTACTS\020\002\022\027\n\023CONVO_INFO_VOLA" + + "TILE\020\003\022\n\n\006GROUPS\020\004\022\025\n\021CLOSED_GROUP_INFO\020" + + "\005\022\030\n\024CLOSED_GROUP_MEMBERS\020\006\022\023\n\017ENCRYPTIO" + + "N_KEYS\020\007\"u\n\016ReceiptMessage\0220\n\004type\030\001 \002(\016" + + "2\".signalservice.ReceiptMessage.Type\022\021\n\t" + + "timestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010DELIVERY\020\000\022\010\n" + + "\004READ\020\001\"\354\001\n\021AttachmentPointer\022\n\n\002id\030\001 \002(" + + "\006\022\023\n\013contentType\030\002 \001(\t\022\013\n\003key\030\003 \001(\014\022\014\n\004s" + + "ize\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(\014\022\016\n\006digest\030\006" + + " \001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005flags\030\010 \001(\r\022\r\n", + "\005width\030\t \001(\r\022\016\n\006height\030\n \001(\r\022\017\n\007caption\030" + + "\013 \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags\022\021\n\rVOICE_MES" + + "SAGE\020\001\"\365\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022.\n\004t" + + "ype\030\002 \001(\0162 .signalservice.GroupContext.T" + + "ype\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030\004 \003(\t\0220\n\006av" + + "atar\030\005 \001(\0132 .signalservice.AttachmentPoi" + + "nter\022\016\n\006admins\030\006 \003(\t\"H\n\004Type\022\013\n\007UNKNOWN\020" + + "\000\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022\010\n\004QUIT\020\003\022\020\n\014" + + "REQUEST_INFO\020\004B3\n\034org.session.libsignal." + + "protosB\023SignalServiceProtos" }; com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner = new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() { @@ -26246,7 +27280,7 @@ public final class SignalServiceProtos { internal_static_signalservice_Content_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_Content_descriptor, - new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", }); + new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", "SharedConfigMessage", }); internal_static_signalservice_KeyPair_descriptor = getDescriptor().getMessageTypes().get(4); internal_static_signalservice_KeyPair_fieldAccessorTable = new @@ -26343,20 +27377,26 @@ public final class SignalServiceProtos { com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_MessageRequestResponse_descriptor, new java.lang.String[] { "IsApproved", "ProfileKey", "Profile", }); - internal_static_signalservice_ReceiptMessage_descriptor = + internal_static_signalservice_SharedConfigMessage_descriptor = getDescriptor().getMessageTypes().get(10); + internal_static_signalservice_SharedConfigMessage_fieldAccessorTable = new + com.google.protobuf.GeneratedMessage.FieldAccessorTable( + internal_static_signalservice_SharedConfigMessage_descriptor, + new java.lang.String[] { "Kind", "Seqno", "Data", }); + internal_static_signalservice_ReceiptMessage_descriptor = + getDescriptor().getMessageTypes().get(11); internal_static_signalservice_ReceiptMessage_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_ReceiptMessage_descriptor, new java.lang.String[] { "Type", "Timestamp", }); internal_static_signalservice_AttachmentPointer_descriptor = - getDescriptor().getMessageTypes().get(11); + getDescriptor().getMessageTypes().get(12); internal_static_signalservice_AttachmentPointer_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_AttachmentPointer_descriptor, new java.lang.String[] { "Id", "ContentType", "Key", "Size", "Thumbnail", "Digest", "FileName", "Flags", "Width", "Height", "Caption", "Url", }); internal_static_signalservice_GroupContext_descriptor = - getDescriptor().getMessageTypes().get(12); + getDescriptor().getMessageTypes().get(13); internal_static_signalservice_GroupContext_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_GroupContext_descriptor, diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt index 154b91ee20..26c62ba50d 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt @@ -1,12 +1,15 @@ package org.session.libsignal.utilities enum class IdPrefix(val value: String) { - STANDARD("05"), BLINDED("15"), UN_BLINDED("00"); + STANDARD("05"), BLINDED("15"), UN_BLINDED("00"), BLINDEDV2("25"); + + fun isBlinded() = value == BLINDED.value || value == BLINDEDV2.value companion object { fun fromValue(rawValue: String): IdPrefix? = when(rawValue.take(2)) { STANDARD.value -> STANDARD BLINDED.value -> BLINDED + BLINDEDV2.value -> BLINDEDV2 UN_BLINDED.value -> UN_BLINDED else -> null } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt index 1c635d9934..ba04e516aa 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt @@ -1,7 +1,7 @@ package org.session.libsignal.utilities object Namespace { + const val ALL = "all" const val DEFAULT = 0 const val UNAUTHENTICATED_CLOSED_GROUP = -10 - const val CONFIGURATION = 5 } \ No newline at end of file diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt index cfbedb7338..28f8aeb03b 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -5,12 +5,16 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) { public enum class Method(val rawValue: String) { GetSwarm("get_snodes_for_pubkey"), - GetMessages("retrieve"), + Retrieve("retrieve"), SendMessage("store"), DeleteMessage("delete"), OxenDaemonRPCCall("oxend_request"), Info("info"), - DeleteAll("delete_all") + DeleteAll("delete_all"), + Batch("batch"), + Sequence("sequence"), + Expire("expire"), + GetExpiries("get_expiries") } data class KeySet(val ed25519Key: String, val x25519Key: String) diff --git a/settings.gradle b/settings.gradle index 3a42510472..7ab26e097c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ rootProject.name = "session-android" include ':app' include ':liblazysodium' include ':libsession' -include ':libsignal' \ No newline at end of file +include ':libsignal' +include ':libsession-util'