Strings work

Squashed commit of the following:

commit 86cab0e11e
Author: ThomasSession <thomas.r@getsession.org>
Date:   Fri Aug 30 10:17:04 2024 +1000

    Bringing my xml dialog styling from my 'Standardise message deletion' branch

commit 706d1aadd8
Author: ThomasSession <thomas.r@getsession.org>
Date:   Fri Aug 30 09:49:48 2024 +1000

    fixing up clear data dialog

    Removing unused code

commit f90599451f
Author: Al Lansley <al@oxen.io>
Date:   Fri Aug 30 09:13:51 2024 +1000

    Replaced 'now' with 12/24 hour time

commit 16b8ad46c0
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 17:34:03 2024 +1000

    Fix two one-liner issues

commit 4c6c450b32
Merge: 052f910d69 beb89d5b74
Author: ThomasSession <thomas.r@getsession.org>
Date:   Thu Aug 29 17:07:16 2024 +1000

    Merge branch 'strings-squashed' of https://github.com/oxen-io/session-android into strings-squashed

commit 052f910d69
Author: ThomasSession <thomas.r@getsession.org>
Date:   Thu Aug 29 17:06:53 2024 +1000

    More bold fixing

commit beb89d5b74
Author: fanchao <git@fanchao.dev>
Date:   Thu Aug 29 17:00:37 2024 +1000

    Fix incorrect group member left message

commit 5773f05a5c
Merge: d35482daba 1cec477020
Author: ThomasSession <thomas.r@getsession.org>
Date:   Thu Aug 29 15:21:44 2024 +1000

    Merge branch 'strings-squashed' of https://github.com/oxen-io/session-android into strings-squashed

commit d35482daba
Author: ThomasSession <thomas.r@getsession.org>
Date:   Thu Aug 29 15:20:13 2024 +1000

    More bold fixes and UI tweaks

commit 78a9ab7159
Author: ThomasSession <thomas.r@getsession.org>
Date:   Thu Aug 29 14:03:41 2024 +1000

    Making sure we bold appropriately

commit 1cec477020
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 13:33:50 2024 +1000

    Made call to 'getQuantityString' pass the count twice because otherwise it doesn't work correctly

commit 8e80ab08a9
Author: ThomasSession <thomas.r@getsession.org>
Date:   Thu Aug 29 13:28:54 2024 +1000

    Using the existing implementation

commit cb9554ab38
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 12:32:30 2024 +1000

    Merge CrowdIn strings circa 2024-08-29

commit dd57da70f6
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 09:06:22 2024 +1000

    Updated Phrase usage in ConversationAdapter

commit 34b15d7865
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 09:03:55 2024 +1000

    Converted TransferControlView into Kotlin and updated Phrase usage

commit a35a7a6a96
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 08:55:16 2024 +1000

    Converted MessageReceipientNotificationBuilder to Kotlin & updated Phrase usage

commit 6dd93b33f2
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 08:25:24 2024 +1000

    Update MuteDialog, LinkPreviewDialog, and PathActivity

commit e7dd1c582d
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 08:16:09 2024 +1000

    Updated DisappearingMessages.kt and HelpSettingsActivity.kt

commit 5bd55ea993
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 08:01:30 2024 +1000

    Converted SwitchPreferenceCompat to Kotlin and fixed the BlockedDialog using the joinCommunity string for some bizarre reason

commit d3fb440d05
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 07:15:03 2024 +1000

    Removed R.string.gif and replaced with a string constant

commit ace58e3493
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 29 07:11:53 2024 +1000

    getSubbedString correction

commit 2a8f010369
Merge: ce8efd7def 116bef3c71
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 16:31:43 2024 +1000

    Merge branch 'compose-open-url-dialog' into strings-squashed

commit ce8efd7def
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 16:31:11 2024 +1000

    WIP

commit 114066ad5f
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 15:30:02 2024 +1000

    Push before changing over all the Phrase.from to extension method calls

commit 116bef3c71
Author: ThomasSession <thomas.r@getsession.org>
Date:   Wed Aug 28 15:25:03 2024 +1000

    For safety

commit 0b1a71a582
Author: ThomasSession <thomas.r@getsession.org>
Date:   Wed Aug 28 15:23:02 2024 +1000

    Cleaning other use of old url dialog

commit 20abbebf4a
Author: ThomasSession <thomas.r@getsession.org>
Date:   Wed Aug 28 15:19:46 2024 +1000

    Forgot !!

commit 25132c6342
Author: ThomasSession <thomas.r@getsession.org>
Date:   Wed Aug 28 15:13:58 2024 +1000

    Proper set up for the Open URL dialog

commit 1f68791da9
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 14:35:05 2024 +1000

    Replaced placeholder text with new string

commit 8d97f31b4d
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 14:31:52 2024 +1000

    Adjusted comment

commit dfebe6f3f9
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 14:25:23 2024 +1000

    Moved block/unblock string selection logic into ViewModel and fixed a comment

commit 736b5313e6
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 14:02:54 2024 +1000

    Changed toast to warning - although condition to trigger should not be possible

commit 413bc0be4b
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 13:55:04 2024 +1000

    Adjusted EditGroupMembers to match iOS and fixed up save attachment commentary / logic

commit ae7164ecbb
Merge: 5df981bc7a d1c4283f42
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 09:51:58 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 2aa58f4dd6
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 28 08:27:03 2024 +1000

    WIP compose openURL dialog

commit 5df981bc7a
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 27 15:51:38 2024 +1000

    Adjusted NotificationRadioButton that takes string IDs to act as a pass-through

commit 96453f1f1e
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 27 15:42:33 2024 +1000

    Added some TODO markers for tomorrow

commit a402a1be79
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 27 15:33:55 2024 +1000

    Adjusted Landing page string substitutions to cater for emojis

commit 4809b5444b
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 27 15:12:39 2024 +1000

    Removed unused 'isEmpty' utility methods

commit b52048a080
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 27 14:42:57 2024 +1000

    Addressed many aspects of PR feedback + misc. strings issues

commit 9cdbc4b80b
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 27 09:50:51 2024 +1000

    Adjusted strings as per Rebecca's 'String Changes' spreadsheet

commit 4d7e4b9e2c
Merge: 3c576053a3 1393335121
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 27 08:19:53 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 3c576053a3
Author: alansley <aclansley@gmail.com>
Date:   Mon Aug 26 17:11:45 2024 +1000

    Moved  into libsession for ease of access to control message view creation

commit b908a54a44
Merge: 404fb8001c bfbe4a8fd2
Author: alansley <aclansley@gmail.com>
Date:   Mon Aug 26 11:54:09 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 404fb8001c
Author: alansley <aclansley@gmail.com>
Date:   Mon Aug 26 11:52:41 2024 +1000

    Performed a PR pass to fix up anything obvious - there's still a few things left TODO

commit 53978f818d
Author: Al Lansley <al@oxen.io>
Date:   Fri Aug 23 14:13:11 2024 +1000

    Cleaned up HomeActivityTests.kt

commit 5f82571bef
Merge: 69b8bd7396 8deb21c0c6
Author: Al Lansley <al@oxen.io>
Date:   Fri Aug 23 08:59:21 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 69b8bd7396
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 16:20:17 2024 +1000

    Added back app_name string so app names properly, fixed API 28 save issue, made some buttons display as red if they should

commit e3cab9c0d9
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 14:26:48 2024 +1000

    SS-75 Prevented ScrollView vertical scroll bar from fading out

commit b0b835092d
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 14:07:49 2024 +1000

    SS-64 Removed all 'Unblocked {name}' toasts as per instructions

commit c3c35de408
Merge: efc2ee2824 8e10e1abf4
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 13:43:00 2024 +1000

    Merge branch 'dev' into strings-squashed

commit efc2ee2824
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 13:40:59 2024 +1000

    Added some comments about the new CrowdIn strings

commit 7a03fb37ef
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 13:08:03 2024 +1000

    Initial integration of CrowdIn strings (English only)

commit 9766c3fd0b
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 09:55:14 2024 +1000

    SS-75 Added 'Copied' toast when the user copies a URL in the Open URL dialog

commit 59b4805b8b
Author: alansley <aclansley@gmail.com>
Date:   Thu Aug 22 09:51:01 2024 +1000

    SS-75 Prevent 'Are you sure you want to open this URL?' dialog from being excessively tall when given a very long URL

commit b7f627f03c
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 21 14:54:17 2024 +1000

    Made closed group deleting-someone-elses msgs use 'Delete message' or 'Delete Messages' appropriately

commit 69f6818f99
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 21 13:53:58 2024 +1000

    Adjusted SS-64 so that all Block / Unblock buttons now use that text and are displayed in red

commit 2192c2c007
Merge: 2338bb47ca eea54d1a17
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 21 13:28:16 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 2338bb47ca
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 19:11:40 2024 +1000

    Converted DefaultMessageNotifier to Kotlin because it needs adjustment & that Java is nasty

commit 6b29e4d8ce
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 17:53:27 2024 +1000

    Added a note about the plurals for search results

commit f7748a0c05
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 16:06:24 2024 +1000

    Corrected text on storage permission dialog

commit f6b6256598
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 14:44:25 2024 +1000

    Minor cleanup of BlockedContactsActivity

commit e3d4870d81
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 14:41:14 2024 +1000

    Addressed changes to fix SS-64 / QA-146 - unblocking contacts modal & toast adjustments

commit e812527358
Merge: 5e02e1ef5c 9919f716a7
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 13:27:35 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 5e02e1ef5c
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 09:39:16 2024 +1000

    Added 'NonTranslatableStringConstants' file

commit 816f21bb29
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 20 09:30:30 2024 +1000

    Addressed commit feedback & removed desktop string 'attachmentsClickToDownload' as we use 'attachmentsTapToDownload'

commit acc8d47c68
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 19 16:22:08 2024 +1000

    SES-1571 Large messages show warning toast

commit 27ca77d5c4
Merge: 27bc90bf1f f379604c54
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 19 11:19:27 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 27bc90bf1f
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 19 08:59:38 2024 +1000

    Cleaned up some comments and content description

commit 558684a56d
Merge: 90d7064c18 93a28906fb
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 19 08:41:47 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 90d7064c18
Author: Al Lansley <al@oxen.io>
Date:   Thu Aug 15 12:13:30 2024 +1000

    Fixed issue where new closed groups would display a timestamp instead of the 'groupNoMessages' text

commit 51ef0ec81c
Author: Al Lansley <al@oxen.io>
Date:   Thu Aug 15 09:45:28 2024 +1000

    Replaced string 'CreateProfileActivity_profile_photo' with the string 'photo' which has the same text ('Photo')

commit eecce08c25
Merge: 01009cf521 5a248da445
Author: Al Lansley <al@oxen.io>
Date:   Thu Aug 15 09:38:10 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 01009cf521
Author: Al Lansley <al@oxen.io>
Date:   Thu Aug 15 08:37:19 2024 +1000

    Changed allowed emoji reactions per minute from 5 (which I used for testing) to 20 (production)

commit 9441d1e08d
Author: Al Lansley <al@oxen.io>
Date:   Thu Aug 15 08:34:16 2024 +1000

    Refactored emoji rate limiter to use a timestamp mechanism rather than removing queue items after a delay

commit 6cd6cc3e26
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 14 16:48:07 2024 +1000

    Adjusted emoji rate limit to 20 reactions per minute to match acceptance criteria

commit edd154d8e1
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 14 16:02:16 2024 +1000

    SS-78 / SES-199 Mechanism required to limit emoji reaction rate

commit a8ee5c9f3b
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 14 14:51:40 2024 +1000

    Replaced hard-coded 'Session' with '{app_name}' in 'callsSessionCall'

commit 621094ebe4
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 14 13:40:01 2024 +1000

    SS-72 Update save attachment models + add one-time warning that other apps can access saved attachments

commit 0c83606539
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 13 15:50:35 2024 +1000

    SS-75 Open URL modal change

commit 802cf19598
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 12 16:42:15 2024 +1000

    Open or copy URL WIP

commit ea84aa1478
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 12 14:17:04 2024 +1000

    Tied in bandDeleteAll string

commit 93b8e74f2d
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 12 11:34:03 2024 +1000

    Job done! All Accessibility ID strings mapped and/or dealt with appropriately!

commit fc3b4ad367
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 12 09:49:57 2024 +1000

    Further AccessibilityId mapping & fixed group members counts to display correct details

commit 558d6741b1
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 9 17:24:44 2024 +1000

    End of day push

commit 73fdb16214
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 9 15:57:06 2024 +1000

    Localised time strings working - even if the unit tests aren't

commit 436175d146
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 9 13:54:09 2024 +1000

    Relative time string WIP

commit f309263e39
Merge: 45c4118d52 007e705cd9
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 9 11:39:13 2024 +1000

    Merge dev

commit 45c4118d52
Author: Al Lansley <al@oxen.io>
Date:   Thu Aug 8 16:43:02 2024 +1000

    Further AccessibilityId mapping WIP

commit 31bac8e30e
Author: Al Lansley <al@oxen.io>
Date:   Thu Aug 8 10:53:30 2024 +1000

    Further accessibility ID changes & removed fragment_new_conversation_home.xml

commit 9c2111e66e
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 7 13:13:52 2024 +1000

    AccessibilityId WIP

commit 1e9eeff86a
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 7 11:06:39 2024 +1000

    AccessibilityId adjustments & removed some unused XML layouts

commit e5fd2c8cc0
Author: alansley <aclansley@gmail.com>
Date:   Wed Aug 7 09:22:14 2024 +1000

    AccessibilityId refactor WIP

commit 399796bac3
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 6 15:51:53 2024 +1000

    AccessibilityId WIP - up to AccessibilityId_reveal_recovery_phrase_button

commit a8d72dfcc0
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 6 14:12:10 2024 +1000

    Cleaned up a few comments and fixed some plurals logic

commit be400d8f4f
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 6 11:32:08 2024 +1000

    Removed commented out merge conflict marker

commit 5cbe289a8d
Merge: 5fe123e7b5 d6c5ab2b18
Author: alansley <aclansley@gmail.com>
Date:   Tue Aug 6 11:30:50 2024 +1000

    Merge dev and cleanup

commit 5fe123e7b5
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 5 14:37:47 2024 +1000

    Adjusted sending of mms messages to show 'Uploading' rather than 'Sending' as per SES-1721

commit d3f8e928b6
Merge: 00552930e6 cd1a0643e3
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 5 13:30:03 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 00552930e6
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 5 13:28:55 2024 +1000

    Removed unused helpReportABugDesktop strings

commit 6c0450b487
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 5 12:59:15 2024 +1000

    Renamed 'quitButton' string to just 'quit'

commit 284c485903
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 5 12:00:35 2024 +1000

    Replaced 'screenSecurity' with 'screenshotNotifications' as the title of the notifications toggle

commit 6948d64fa8
Author: Al Lansley <al@oxen.io>
Date:   Mon Aug 5 10:45:05 2024 +1000

    WIP

commit bc94cb78db
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 2 16:21:16 2024 +1000

    End of day push

commit 1a2df3798a
Merge: c7fdb6aed9 a56e1d0b91
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 2 15:20:19 2024 +1000

    Merged dev

commit c7fdb6aed9
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 2 14:21:11 2024 +1000

    Replaced string 'dialog_disappearing_messages_follow_setting_confirm' with 'confirm'

commit 2992d590d9
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 2 14:01:00 2024 +1000

    Removed string 'attachment_type_selector__gallery' and associated / un-used 'attachment_type_selector.xml' layout

commit 4218663c95
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 2 13:39:54 2024 +1000

    Removed 'message_details_header__disappears' and the unused 'activity_message_detail.xml' which was the only reference to it

commit ba2d0275e4
Author: alansley <aclansley@gmail.com>
Date:   Fri Aug 2 12:15:42 2024 +1000

    Implemented task SS-79 to only provide a save attachment menu option when the attachment download is complete

commit 20662c8222
Merge: 608c984a6b fbbef4898a
Author: alansley <aclansley@gmail.com>
Date:   Wed Jul 31 13:08:04 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 608c984a6b
Author: alansley <aclansley@gmail.com>
Date:   Tue Jul 30 16:58:08 2024 +1000

    Actually remove the 4 specific time period mute strings

commit 006a4e8bad
Author: alansley <aclansley@gmail.com>
Date:   Tue Jul 30 16:43:54 2024 +1000

    Cleaned up MuteDialog.kt

commit d3177f9f1a
Author: alansley <aclansley@gmail.com>
Date:   Tue Jul 30 16:27:06 2024 +1000

    Added a 1 second kludge to the mute for subtitle so that it initially shows 1 hour not 59 minutes etc.

commit d568a86649
Author: alansley <aclansley@gmail.com>
Date:   Tue Jul 30 16:20:20 2024 +1000

    Removed 'Muted for' strings and fixed it up to use 'Mute for {large_time_unit}' across the board

commit 84f6f19cf4
Author: alansley <aclansley@gmail.com>
Date:   Tue Jul 30 11:03:46 2024 +1000

    Changed some hard-coded 'Session' text in strings and renamed another

commit bc90d18c91
Author: alansley <aclansley@gmail.com>
Date:   Tue Jul 30 10:27:55 2024 +1000

    Cleaned up a leftover plural & changed 'app_name' to use 'sessionMessenger' string

commit 79cd87878c
Merge: 3b62e474b3 dec02cef5a
Author: alansley <aclansley@gmail.com>
Date:   Tue Jul 30 08:16:02 2024 +1000

    Merge branch 'dev' into strings-squashed

commit 3b62e474b3
Author: Al Lansley <al@oxen.io>
Date:   Mon Jul 29 16:33:21 2024 +1000

    Down to just the final few straggler strings

commit 13e81f046b
Author: Al Lansley <al@oxen.io>
Date:   Mon Jul 29 13:13:54 2024 +1000

    WIP

commit 2d9961d5c0
Author: Al Lansley <al@oxen.io>
Date:   Mon Jul 29 08:58:01 2024 +1000

    Further cleanup of stragglers

commit 08b8a84309
Author: Al Lansley <al@oxen.io>
Date:   Mon Jul 29 08:29:12 2024 +1000

    Cleaning up straggler strings

commit d0e87c64b5
Author: alansley <aclansley@gmail.com>
Date:   Fri Jul 26 17:07:46 2024 +1000

    WIP

commit 4bc9d09be2
Author: alansley <aclansley@gmail.com>
Date:   Fri Jul 26 16:30:28 2024 +1000

    WIP

commit 3cee4bc12f
Merge: aa1db13e3a a495ec232a
Author: alansley <aclansley@gmail.com>
Date:   Fri Jul 26 13:57:09 2024 +1000

    Removed some legacy strings & substituted others

commit aa1db13e3a
Author: fanchao <git@fanchao.dev>
Date:   Fri Jul 26 11:34:05 2024 +1000

    Initial squash merge for strings
This commit is contained in:
fanchao
2024-09-06 11:21:03 +10:00
parent 67bcc937ce
commit d41496a997
279 changed files with 37625 additions and 16137 deletions

View File

@@ -1,7 +1,8 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlinx-serialization'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp'
id 'com.google.dagger.hilt.android'
}
@@ -11,6 +12,8 @@ android {
defaultConfig {
minSdkVersion androidMinimumSdkVersion
// Build constant for which version should minimally support new groups
buildConfigField("String", "MINIMUM_GROUP_VERSION", '"1.19.0"')
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -29,6 +32,10 @@ android {
}
}
buildFeatures {
buildConfig true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
@@ -51,7 +58,6 @@ dependencies {
ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion")
implementation "net.java.dev.jna:jna:5.12.1@aar"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation "androidx.preference:preference-ktx:$preferenceVersion"
@@ -67,7 +73,6 @@ dependencies {
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.phrase:phrase:$phraseVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"

View File

@@ -24,6 +24,7 @@ interface MessageDataProvider {
fun deleteMessage(messageID: Long, isSms: Boolean)
fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean)
fun updateMessageAsDeleted(timestamp: Long, author: String): Long?
fun updateMessageAsDeleted(messageId: Long, isSms: Boolean): Long
fun getServerHashForMessage(messageID: Long, mms: Boolean): String?
fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment?
fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream?

View File

@@ -0,0 +1,8 @@
package org.session.libsession.database
data class ServerHashToMessageId(
val serverHash: String,
val sender: String,
val messageId: Long,
val isSms: Boolean,
)

View File

@@ -2,7 +2,11 @@ package org.session.libsession.database
import android.content.Context
import android.net.Uri
import network.loki.messenger.libsession_util.ConfigBase
import com.goterl.lazysodium.utils.KeyPair
import network.loki.messenger.libsession_util.Config
import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import network.loki.messenger.libsession_util.util.GroupInfo
import nl.komponents.kovenant.Promise
import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact
@@ -13,6 +17,7 @@ import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.Profile
@@ -26,6 +31,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
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.quotes.QuoteModel
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient
@@ -33,12 +39,16 @@ 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 org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.guava.Optional
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember
interface StorageProtocol {
// General
fun getUserPublicKey(): String?
fun getUserED25519KeyPair(): KeyPair?
fun getUserX25519KeyPair(): ECKeyPair
fun getUserProfile(): Profile
fun setProfileAvatar(recipient: Recipient, profileAvatar: String?)
@@ -128,7 +138,7 @@ interface StorageProtocol {
fun clearErrorMessage(messageID: Long)
fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String)
// Closed Groups
// Legacy Closed Groups
fun getGroup(groupID: String): GroupRecord?
fun createGroup(groupID: String, title: String?, members: List<Address>, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List<Address>, formationTimestamp: Long)
fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int)
@@ -145,16 +155,41 @@ interface StorageProtocol {
fun removeClosedGroupPublicKey(groupPublicKey: String)
fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long)
fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String)
fun removeClosedGroupThread(threadID: Long)
fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type,
name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long)
name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long): Long?
fun updateInfoMessage(context: Context, messageId: Long, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>)
fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String,
members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long)
fun isClosedGroup(publicKey: String): Boolean
members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long): Long?
fun isLegacyClosedGroup(publicKey: String): Boolean
fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList<ECKeyPair>
fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair?
fun updateFormationTimestamp(groupID: String, formationTimestamp: Long)
fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long)
// Closed Groups
fun createNewGroup(groupName: String, groupDescription: String, members: Set<Contact>): Optional<Recipient>
fun getMembers(groupPublicKey: String): List<LibSessionGroupMember>
fun respondToClosedGroupInvitation(threadId: Long, groupRecipient: Recipient, approved: Boolean)
fun addClosedGroupInvite(groupId: AccountId, name: String, authData: ByteArray?, adminKey: ByteArray?, invitingAdmin: AccountId, invitingMessageHash: String?)
fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: AccountId)
fun getLibSessionClosedGroup(groupAccountId: String): GroupInfo.ClosedGroupInfo?
fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo?
fun inviteClosedGroupMembers(groupAccountId: String, invitees: List<String>)
fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long?
fun insertGroupInfoLeaving(closedGroup: AccountId): Long?
fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind)
fun promoteMember(groupAccountId: AccountId, promotions: List<AccountId>)
suspend fun removeMember(groupAccountId: AccountId, removedMembers: List<AccountId>, removeMessages: Boolean)
suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId)
fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId)
fun handleKicked(groupAccountId: AccountId)
fun leaveGroup(groupSessionId: String, deleteOnLeave: Boolean): Boolean
fun setName(groupSessionId: String, newName: String)
fun sendGroupUpdateDeleteMessage(groupSessionId: String, messageHashes: List<String>): Promise<Unit, Exception>
// Groups
fun getAllGroups(includeInactive: Boolean): List<GroupRecord>
@@ -179,6 +214,8 @@ interface StorageProtocol {
fun setThreadDate(threadId: Long, newDate: Long)
fun getLastLegacyRecipient(threadRecipient: String): String?
fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?)
fun clearMessages(threadID: Long, fromUser: Address? = null): Boolean
fun clearMedia(threadID: Long, fromUser: Address? = null): Boolean
// Contacts
fun getContactWithAccountID(accountID: String): Contact?
@@ -187,7 +224,10 @@ interface StorageProtocol {
fun getRecipientForThread(threadId: Long): Recipient?
fun getRecipientSettings(address: Address): RecipientSettings?
fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long)
fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean
fun addContacts(contacts: List<ConfigurationMessage.Contact>)
fun shouldAutoDownloadAttachments(recipient: Recipient): Boolean
fun setAutoDownloadAttachments(recipient: Recipient, shouldAutoDownloadAttachments: Boolean)
// Attachments
fun getAttachmentDataUri(attachmentId: AttachmentId): Uri
@@ -200,6 +240,7 @@ interface StorageProtocol {
fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List<LinkPreview?>, groupPublicKey: String?, openGroupID: String?, attachments: List<Attachment>, runThreadUpdate: Boolean): Long?
fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false)
fun getLastSeen(threadId: Long): Long
fun ensureMessageHashesAreSender(hashes: Set<String>, sender: String, closedGroupId: String): Boolean
fun updateThread(threadId: Long, unarchive: Boolean)
fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long)
fun insertMessageRequestResponse(response: MessageRequestResponse)
@@ -208,6 +249,8 @@ interface StorageProtocol {
fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean)
fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long)
fun conversationHasOutgoing(userPublicKey: String): Boolean
fun deleteMessagesByHash(threadId: Long, hashes: List<String>)
fun deleteMessagesByUser(threadId: Long, userSessionId: String)
// Last Inbox Message Id
fun getLastInboxMessageId(server: String): Long?
@@ -237,8 +280,8 @@ interface StorageProtocol {
)
// Shared configs
fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long)
fun notifyConfigUpdates(forConfigObject: Config, messageTimestamp: Long)
fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean
fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean
fun isCheckingCommunityRequests(): Boolean
}
}

View File

@@ -0,0 +1,15 @@
package org.session.libsession.database
import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsignal.utilities.AccountId
val StorageProtocol.userAuth: OwnedSwarmAuth?
get() = getUserPublicKey()?.let { accountId ->
getUserED25519KeyPair()?.let { keyPair ->
OwnedSwarmAuth(
accountId = AccountId(hexString = accountId),
ed25519PublicKeyHex = keyPair.publicKey.asHexString,
ed25519PrivateKey = keyPair.secretKey.asBytes
)
}
}

View File

@@ -4,17 +4,22 @@ 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.messaging.notifications.TokenFetcher
import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.Toaster
import org.session.libsignal.utilities.AccountId
class MessagingModuleConfiguration(
val context: Context,
val storage: StorageProtocol,
val device: Device,
val messageDataProvider: MessageDataProvider,
val getUserED25519KeyPair: () -> KeyPair?,
val configFactory: ConfigFactoryProtocol,
val lastSentTimestampCache: LastSentTimestampCache
val lastSentTimestampCache: LastSentTimestampCache,
val toaster: Toaster,
val tokenFetcher: TokenFetcher,
) {
companion object {

View File

@@ -1,38 +1,40 @@
package org.session.libsession.messaging.contacts
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.session.libsession.utilities.recipients.Recipient
class Contact(val accountID: String) {
@Parcelize
class Contact(
val accountID: String,
/**
* The URL from which to fetch the contact's profile picture.
*/
var profilePictureURL: String? = null
var profilePictureURL: String? = null,
/**
* The file name of the contact's profile picture on local storage.
*/
var profilePictureFileName: String? = null
var profilePictureFileName: String? = null,
/**
* The key with which the profile picture is encrypted.
*/
var profilePictureEncryptionKey: ByteArray? = null
var profilePictureEncryptionKey: ByteArray? = null,
/**
* The ID of the thread associated with this contact.
*/
var threadID: Long? = null
/**
* This flag is used to determine whether we should auto-download files sent by this contact.
*/
var isTrusted = false
// region Name
var threadID: Long? = null,
/**
* The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message).
*/
var name: String? = null
var name: String? = null,
/**
* The contact's nickname, if the user set one.
*/
var nickname: String? = null
var nickname: String? = null,
): Parcelable {
constructor(id: String): this(accountID = id)
/**
* The name to display in the UI. For local use only.
*/

View File

@@ -123,7 +123,7 @@ object FileServerApi {
*/
suspend fun getClientVersion(): VersionData {
// Generate the auth signature
val secretKey = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes
val secretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()?.secretKey?.asBytes
?: throw (Error.NoEd25519KeyPair)
val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey)

View File

@@ -0,0 +1,201 @@
package org.session.libsession.messaging.groups
import android.os.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import network.loki.messenger.libsession_util.GroupKeysConfig
import network.loki.messenger.libsession_util.util.GroupMember
import network.loki.messenger.libsession_util.util.Sodium
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeMessage
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.withGroupConfigsOrNull
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
private const val TAG = "RemoveGroupMemberHandler"
private const val MIN_PROCESS_INTERVAL_MILLS = 1_000L
class RemoveGroupMemberHandler(
private val configFactory: ConfigFactoryProtocol,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
) {
init {
scope.launch {
while (true) {
val processStartedAt = SystemClock.uptimeMillis()
try {
processPendingMemberRemoval()
} catch (e: Exception) {
Log.e(TAG, "Error processing pending member removal", e)
}
configFactory.configUpdateNotifications.firstOrNull()
// Make sure we don't process too often. As some of the config changes don't apply
// to us, but we have no way to tell if it does or not. The safest way is to process
// everytime any config changes, with a minimum interval.
val delayMills =
MIN_PROCESS_INTERVAL_MILLS - (SystemClock.uptimeMillis() - processStartedAt)
if (delayMills > 0) {
delay(delayMills)
}
}
}
}
private suspend fun processPendingMemberRemoval() {
val userGroups = checkNotNull(configFactory.userGroups) {
"User groups config is null"
}
// Run the removal process for each group in parallel
val removalTasks = userGroups.allClosedGroupInfo()
.asSequence()
.filter { it.hasAdminKey() }
.associate { group ->
group.name to scope.async {
processPendingRemovalsForGroup(
groupAccountId = group.groupAccountId,
groupName = group.name,
adminKey = group.adminKey!!
)
}
}
// Wait and collect the results of the removal tasks
for ((groupName, task) in removalTasks) {
try {
task.await()
} catch (e: Exception) {
Log.e(TAG, "Error processing pending removals for group $groupName", e)
}
}
}
private fun processPendingRemovalsForGroup(
groupAccountId: AccountId,
groupName: String,
adminKey: ByteArray
) {
val swarmAuth = OwnedSwarmAuth(
accountId = groupAccountId,
ed25519PublicKeyHex = null,
ed25519PrivateKey = adminKey
)
configFactory.withGroupConfigsOrNull(groupAccountId) withConfig@ { info, members, keys ->
val pendingRemovals = members.all().filter { it.removed }
if (pendingRemovals.isEmpty()) {
// Skip if there are no pending removals
return@withConfig
}
Log.d(TAG, "Processing ${pendingRemovals.size} pending removals for group $groupName")
// Perform a sequential call to group snode to:
// 1. Revoke the member's sub key (by adding the key to a "revoked list" under the hood)
// 2. Send a message to a special namespace to inform the removed members they have been removed
// 3. Conditionally, delete removed-members' messages from the group's message store, if that option is selected by the actioning admin
val seqCalls = ArrayList<SnodeAPI.SnodeBatchRequestInfo>(3)
// Call No 1. Revoke sub-key. This call is crucial and must not fail for the rest of the operation to be successful.
seqCalls += checkNotNull(
SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest(
groupAdminAuth = swarmAuth,
subAccountTokens = pendingRemovals.map {
keys.getSubAccountToken(AccountId(it.sessionId))
}
)
) { "Fail to create a revoke request" }
// Call No 2. Send a message to the removed members
seqCalls += SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.REVOKED_GROUP_MESSAGES(),
message = buildGroupKickMessage(groupAccountId.hexString, pendingRemovals, keys, adminKey),
auth = swarmAuth,
)
// Call No 3. Conditionally remove the message from the group's message store
if (pendingRemovals.any { it.shouldRemoveMessages }) {
seqCalls += SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.CLOSED_GROUP_MESSAGES(),
message = buildDeleteGroupMemberContentMessage(
groupAccountId = groupAccountId.hexString,
memberSessionIDs = pendingRemovals
.asSequence()
.filter { it.shouldRemoveMessages }
.map { it.sessionId }
),
auth = swarmAuth,
)
}
// Make the call:
SnodeAPI.getSingleTargetSnode(groupAccountId.hexString)
}
}
private fun buildDeleteGroupMemberContentMessage(
groupAccountId: String,
memberSessionIDs: Sequence<String>
): SnodeMessage {
return MessageSender.buildWrappedMessageToSnode(
destination = Destination.ClosedGroup(groupAccountId),
message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setDeleteMemberContent(
SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage
.newBuilder()
.apply {
for (id in memberSessionIDs) {
addMemberSessionIds(id)
}
}
)
.build()
),
isSyncMessage = false
)
}
private fun buildGroupKickMessage(
groupAccountId: String,
pendingRemovals: List<GroupMember>,
keys: GroupKeysConfig,
adminKey: ByteArray
) = SnodeMessage(
recipient = groupAccountId,
data = Base64.encodeBytes(
Sodium.encryptForMultipleSimple(
messages = Array(pendingRemovals.size) {
"${pendingRemovals[it].sessionId}${keys.currentGeneration()}".encodeToByteArray()
},
recipients = Array(pendingRemovals.size) {
AccountId(pendingRemovals[it].sessionId).pubKeyBytes
},
ed25519SecretKey = adminKey,
domain = Sodium.KICKED_DOMAIN
)
),
ttl = SnodeMessage.CONFIG_TTL,
timestamp = SnodeAPI.nowWithOffset
)
}

View File

@@ -63,15 +63,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
return true
}
// you can't be eligible without a sender
val sender = messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize()
?: return false
// you can't be eligible without a contact entry
val contact = storage.getContactWithAccountID(sender) ?: return false
// we are eligible if we are receiving a group message or the contact is trusted
return threadRecipient.isGroupRecipient || contact.isTrusted
return storage.shouldAutoDownloadAttachments(threadRecipient)
}
}

View File

@@ -1,13 +1,16 @@
package org.session.libsession.messaging.jobs
import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.task
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.CallMessage
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
@@ -28,10 +31,10 @@ import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactio
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.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsignal.protos.UtilProtos
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import kotlin.math.max
@@ -40,7 +43,8 @@ data class MessageReceiveParameters(
val data: ByteArray,
val serverHash: String? = null,
val openGroupMessageServerID: Long? = null,
val reactions: Map<String, OpenGroupApi.Reaction>? = null
val reactions: Map<String, OpenGroupApi.Reaction>? = null,
val closedGroup: Destination.ClosedGroup? = null
)
class BatchMessageReceiveJob(
@@ -70,6 +74,7 @@ class BatchMessageReceiveJob(
private val SERVER_HASH_KEY = "serverHash"
private val OPEN_GROUP_MESSAGE_SERVER_ID_KEY = "openGroupMessageServerID"
private val OPEN_GROUP_ID_KEY = "open_group_id"
private val CLOSED_GROUP_DESTINATION_KEY = "closed_group_destination"
}
private fun shouldCreateThread(parsedMessage: ParsedMessage): Boolean {
@@ -93,150 +98,161 @@ class BatchMessageReceiveJob(
}
override suspend fun execute(dispatcherName: String) {
executeAsync(dispatcherName).get()
executeAsync(dispatcherName)
}
fun executeAsync(dispatcherName: String): Promise<Unit, Exception> {
return task {
val threadMap = mutableMapOf<Long, MutableList<ParsedMessage>>()
val storage = MessagingModuleConfiguration.shared.storage
val context = MessagingModuleConfiguration.shared.context
val localUserPublicKey = storage.getUserPublicKey()
val serverPublicKey = openGroupID?.let { storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) }
val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys()
suspend fun executeAsync(dispatcherName: String) {
val threadMap = mutableMapOf<Long, MutableList<ParsedMessage>>()
val storage = MessagingModuleConfiguration.shared.storage
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, currentClosedGroups = currentClosedGroups)
message.serverHash = serverHash
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 {
threadMap[threadID]!! += parsedParams
// parse and collect IDs
messages.forEach { messageParameters ->
val (data, serverHash, openGroupMessageServerID) = messageParameters
try {
val (message, proto) = MessageReceiver.parse(
data,
openGroupMessageServerID,
openGroupPublicKey = serverPublicKey,
currentClosedGroups = currentClosedGroups,
closedGroupSessionId = messageParameters.closedGroup?.publicKey
)
message.serverHash = serverHash
val parsedParams = ParsedMessage(messageParameters, message, proto)
val threadID = Message.getThreadId(
message = message,
openGroupID = openGroupID,
storage = storage,
shouldCreateThread = shouldCreateThread(parsedParams)
) ?: NO_THREAD_MAPPING
threadMap.getOrPut(threadID) { mutableListOf() } += parsedParams
} catch (e: Exception) {
when (e) {
is MessageReceiver.Error.DuplicateMessage, MessageReceiver.Error.SelfSend -> {
Log.i(TAG, "Couldn't receive message, failed with error: ${e.message} (id: $id)")
}
} catch (e: Exception) {
when (e) {
is MessageReceiver.Error.DuplicateMessage, MessageReceiver.Error.SelfSend -> {
Log.i(TAG, "Couldn't receive message, failed with error: ${e.message} (id: $id)")
is MessageReceiver.Error -> {
if (!e.isRetryable) {
Log.e(TAG, "Couldn't receive message, failed permanently (id: $id)", e)
}
is MessageReceiver.Error -> {
if (!e.isRetryable) {
Log.e(TAG, "Couldn't receive message, failed permanently (id: $id)", e)
}
else {
Log.e(TAG, "Couldn't receive message, failed (id: $id)", e)
failures += messageParameters
}
}
else -> {
else {
Log.e(TAG, "Couldn't receive message, failed (id: $id)", e)
failures += messageParameters
}
}
else -> {
Log.e(TAG, "Couldn't receive message, failed (id: $id)", e)
failures += messageParameters
}
}
}
}
// iterate over threads and persist them (persistence is the longest constant in the batch process operation)
runBlocking(Dispatchers.IO) {
fun processMessages(threadId: Long, messages: List<ParsedMessage>) = async {
// The LinkedHashMap should preserve insertion order
val messageIds = linkedMapOf<Long, Pair<Boolean, Boolean>>()
val myLastSeen = storage.getLastSeen(threadId)
var newLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0
messages.forEach { (parameters, message, proto) ->
try {
when (message) {
is VisibleMessage -> {
val isUserBlindedSender =
message.sender == serverPublicKey?.let {
SodiumUtilities.blindedKeyPair(
it,
MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
)
}?.let {
AccountId(
IdPrefix.BLINDED, it.publicKey.asBytes
).hexString
}
if (message.sender == localUserPublicKey || isUserBlindedSender) {
// use sent timestamp here since that is technically the last one we have
newLastSeen = max(newLastSeen, message.sentTimestamp!!)
}
val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID,
threadId,
runThreadUpdate = false,
runProfileUpdate = true)
if (messageId != null && message.reaction == null) {
messageIds[messageId] = Pair(
(message.sender == localUserPublicKey || isUserBlindedSender),
message.hasMention
)
}
parameters.openGroupMessageServerID?.let {
MessageReceiver.handleOpenGroupReactions(
threadId,
it,
parameters.reactions
)
}
// iterate over threads and persist them (persistence is the longest constant in the batch process operation)
fun processMessages(threadId: Long, messages: List<ParsedMessage>) {
// The LinkedHashMap should preserve insertion order
val messageIds = linkedMapOf<Long, Pair<Boolean, Boolean>>()
val myLastSeen = storage.getLastSeen(threadId)
var newLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0
messages.forEach { (parameters, message, proto) ->
try {
when (message) {
is VisibleMessage -> {
val isUserBlindedSender =
message.sender == serverPublicKey?.let {
SodiumUtilities.blindedKeyPair(
serverPublicKey = it,
edKeyPair = storage.getUserED25519KeyPair()!!
)
}?.let {
AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString
}
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)
if (message.sender == localUserPublicKey || isUserBlindedSender) {
// use sent timestamp here since that is technically the last one we have
newLastSeen = max(newLastSeen, message.sentTimestamp!!)
}
} 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
val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID,
threadId,
runThreadUpdate = false,
runProfileUpdate = true)
if (messageId != null && message.reaction == null) {
messageIds[messageId] = Pair(
(message.sender == localUserPublicKey || isUserBlindedSender),
message.hasMention
)
}
parameters.openGroupMessageServerID?.let {
MessageReceiver.handleOpenGroupReactions(
threadId,
it,
parameters.reactions
)
}
}
}
// 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 }
newLastSeen = max(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) ->
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 = message,
proto = proto,
threadId = threadId,
openGroupID = openGroupID,
closedGroup = parameters.closedGroup?.publicKey?.let(::AccountId)
)
}
} 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
// 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 }
newLastSeen = max(newLastSeen, currentLastSeen)
if (newLastSeen > 0 || currentLastSeen == 0L) {
storage.markConversationAsRead(threadId, newLastSeen, force = true)
}
storage.updateThread(threadId, true)
SSKEnvironment.shared.notificationManager.updateNotification(context, threadId)
}
coroutineScope {
val withoutDefault = threadMap.entries.filter { it.key != NO_THREAD_MAPPING }
val deferredThreadMap = withoutDefault.map { (threadId, messages) ->
async(Dispatchers.Default) {
processMessages(threadId, messages)
}
// await all thread processing
deferredThreadMap.awaitAll()
if (noThreadMessages.isNotEmpty()) {
processMessages(NO_THREAD_MAPPING, noThreadMessages).await()
}
}
if (failures.isEmpty()) {
handleSuccess(dispatcherName)
} else {
handleFailure(dispatcherName)
}
// await all thread processing
deferredThreadMap.awaitAll()
}
val noThreadMessages = threadMap[NO_THREAD_MAPPING] ?: listOf()
if (noThreadMessages.isNotEmpty()) {
processMessages(NO_THREAD_MAPPING, noThreadMessages)
}
if (failures.isEmpty()) {
handleSuccess(dispatcherName)
} else {
handleFailure(dispatcherName)
}
}
@@ -257,12 +273,14 @@ class BatchMessageReceiveJob(
.build()
val serverHashes = messages.map { it.serverHash.orEmpty() }
val openGroupServerIds = messages.map { it.openGroupMessageServerID ?: -1L }
val closedGroups = messages.map { it.closedGroup?.publicKey.orEmpty() }
return Data.Builder()
.putInt(NUM_MESSAGES_KEY, arraySize)
.putByteArray(DATA_KEY, dataArrays.toByteArray())
.putString(OPEN_GROUP_ID_KEY, openGroupID)
.putLongArray(OPEN_GROUP_MESSAGE_SERVER_ID_KEY, openGroupServerIds.toLongArray())
.putStringArray(SERVER_HASH_KEY, serverHashes.toTypedArray())
.putStringArray(CLOSED_GROUP_DESTINATION_KEY, closedGroups.toTypedArray())
.build()
}
@@ -278,11 +296,22 @@ class BatchMessageReceiveJob(
if (data.hasStringArray(SERVER_HASH_KEY)) data.getStringArray(SERVER_HASH_KEY) else arrayOf()
val openGroupMessageServerIDs = data.getLongArray(OPEN_GROUP_MESSAGE_SERVER_ID_KEY)
val openGroupID = data.getStringOrDefault(OPEN_GROUP_ID_KEY, null)
val closedGroups =
if (data.hasStringArray(CLOSED_GROUP_DESTINATION_KEY)) data.getStringArray(CLOSED_GROUP_DESTINATION_KEY)
else arrayOf()
val parameters = (0 until numMessages).map { index ->
val serverHash = serverHashes[index].let { if (it.isEmpty()) null else it }
val serverId = openGroupMessageServerIDs[index].let { if (it == -1L) null else it }
MessageReceiveParameters(contents[index], serverHash, serverId)
val closedGroup = closedGroups.getOrNull(index)?.let {
if (it.isEmpty()) null else Destination.ClosedGroup(it)
}
MessageReceiveParameters(
data = contents[index],
serverHash = serverHash,
openGroupMessageServerID = serverId,
closedGroup = closedGroup
)
}
return BatchMessageReceiveJob(parameters, openGroupID)

View File

@@ -1,19 +1,30 @@
package org.session.libsession.messaging.jobs
import java.util.concurrent.atomic.AtomicBoolean
import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor
import network.loki.messenger.libsession_util.Config
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.GroupKeysConfig
import nl.komponents.kovenant.functional.bind
import org.session.libsession.database.userAuth
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.OwnedSwarmAuth
import org.session.libsession.snode.RawResponse
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeAPI.SnodeBatchRequestInfo
import org.session.libsession.snode.SnodeMessage
import org.session.libsession.snode.SwarmAuth
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import java.util.concurrent.atomic.AtomicBoolean
class InvalidDestination :
Exception("Trying to push configs somewhere other than our swarm or a closed group")
// only contact (self) and closed group destinations will be supported
data class ConfigurationSyncJob(val destination: Destination): Job {
data class ConfigurationSyncJob(val destination: Destination) : Job {
override var delegate: JobDelegate? = null
override var id: String? = null
@@ -22,119 +33,196 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
val shouldRunAgain = AtomicBoolean(false)
data class ConfigMessageInformation(
val batch: SnodeBatchRequestInfo,
val config: Config,
val seqNo: Long?
) // seqNo will be null for keys type
data class SyncInformation(
val configs: List<ConfigMessageInformation>,
val toDelete: List<String>
)
private fun destinationConfigs(
configFactoryProtocol: ConfigFactoryProtocol
): SyncInformation {
val toDelete = mutableListOf<String>()
val configsRequiringPush =
if (destination is Destination.ClosedGroup) {
// destination is a closed group, get all configs requiring push here
val groupId = AccountId(destination.publicKey)
// Get the signing key for pushing configs
val signingKey = configFactoryProtocol
.userGroups?.getClosedGroup(destination.publicKey)?.adminKey
if (signingKey?.isNotEmpty() == true) {
val info = configFactoryProtocol.getGroupInfoConfig(groupId)!!
val members = configFactoryProtocol.getGroupMemberConfig(groupId)!!
val keys = configFactoryProtocol.getGroupKeysConfig(
groupId,
info,
members,
false
)!!
val requiringPush =
listOf(keys, info, members).filter {
when (it) {
is GroupKeysConfig -> it.pendingConfig()?.isNotEmpty() == true
is ConfigBase -> it.needsPush()
else -> false
}
}
// free the objects that were created but won't be used after this point
// in case any of the configs don't need pushing, they won't be freed later
(listOf(keys, info, members) subtract requiringPush).forEach(Config::free)
val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupId, signingKey)
requiringPush.mapNotNull { config ->
if (config is GroupKeysConfig) {
config.messageInformation(groupAuth)
} else if (config is ConfigBase) {
config.messageInformation(toDelete, groupAuth)
} else {
Log.e("ConfigurationSyncJob", "Tried to create a message from an unknown config")
null
}
}
} else emptyList()
} else if (destination is Destination.Contact) {
val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth) {
"No user auth for syncing user config"
}
// assume our own user as check already takes place in `execute` for our own key
// if contact
configFactoryProtocol.getUserConfigs().filter { it.needsPush() }.map { config ->
config.messageInformation(toDelete, userAuth)
}
} else throw InvalidDestination()
return SyncInformation(configsRequiringPush, toDelete)
}
override suspend fun execute(dispatcherName: String) {
val storage = MessagingModuleConfiguration.shared.storage
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
val userPublicKey = storage.getUserPublicKey()
val delegate = delegate
if (destination is Destination.ClosedGroup
// 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)
val delegate = delegate ?: return Log.e("ConfigurationSyncJob", "No Delegate")
if (destination !is Destination.ClosedGroup &&
(destination !is Destination.Contact ||
destination.publicKey != userPublicKey)
) {
Log.w(TAG, "No need to run config sync job, TODO")
return delegate?.handleJobSucceeded(this, dispatcherName) ?: Unit
return delegate.handleJobFailedPermanently(this, dispatcherName, InvalidDestination())
}
// configFactory singleton instance will come in handy for modifying hashes and fetching configs for namespace etc
// 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<String>()
// allow null results here so the list index matches configsRequiringPush
val sentTimestamp: Long = SnodeAPI.nowWithOffset
val batchObjects: List<Pair<SharedConfigurationMessage, SnodeAPI.SnodeBatchRequestInfo>?> = 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.buildConfigMessageToSnode(destination.destinationPublicKey(), message)
val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo(
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 (batchObjects, toDeleteHashes) =
destinationConfigs(configFactory)
val toDeleteRequest = toDeleteHashes.let { toDeleteFromAllNamespaces ->
if (toDeleteFromAllNamespaces.isEmpty()) null
else SnodeAPI.buildAuthenticatedDeleteBatchInfo(destination.destinationPublicKey(), toDeleteFromAllNamespaces)
}
if (batchObjects.isEmpty()) return delegate.handleJobSucceeded(this, dispatcherName)
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 toDeleteRequest =
toDeleteHashes.let { toDeleteFromAllNamespaces ->
if (toDeleteFromAllNamespaces.isEmpty()) null
else if (destination is Destination.ClosedGroup) {
// Build sign callback for group's admin key
val signingKey =
configFactory.userGroups
?.getClosedGroup(destination.publicKey)
?.adminKey ?: return@let null
val allRequests = mutableListOf<SnodeAPI.SnodeBatchRequestInfo>()
allRequests += batchObjects.requireNoNulls().map { (_, request) -> request }
// Destination is a closed group swarm, build with signCallback
SnodeAPI.buildAuthenticatedDeleteBatchInfo(
OwnedSwarmAuth.ofClosedGroup(
groupAccountId = AccountId(destination.publicKey),
adminKey = signingKey,
),
toDeleteFromAllNamespaces,
)
} else {
// Destination is our own swarm
val userAuth = MessagingModuleConfiguration.shared.storage.userAuth
if (userAuth == null) {
delegate.handleJobFailedPermanently(
this,
dispatcherName,
IllegalStateException("No user auth for syncing user config")
)
return
}
SnodeAPI.buildAuthenticatedDeleteBatchInfo(
auth = userAuth,
messageHashes = toDeleteFromAllNamespaces
)
}
}
val allRequests = mutableListOf<SnodeBatchRequestInfo>()
allRequests += batchObjects.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
)
}
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<RawResponse>)
// 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<String>)?.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]!!
batchObjects.forEachIndexed { index, (message, config, seqNo) ->
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
}
val insertHash =
responseBody?.get("hash") as? String
?: run {
Log.w(
TAG,
"No hash returned for the configuration in namespace ${config.namespace()}"
)
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}")
if (config is ConfigBase) {
seqNo?.let { config.confirmPushed(it, 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)
if (config is ConfigBase && config.needsDump()) { // usually this will be true? ))
val groupPubKey = if (destination is Destination.ClosedGroup) destination.publicKey else null
configFactory.persist(config, message.params["timestamp"] as Long, groupPubKey)
} else if (config is GroupKeysConfig && config.needsDump()) {
Log.d("Loki", "Should persist the GroupKeysConfig")
}
if (destination is Destination.ClosedGroup) {
config.free() // after they are used, free the temporary group configs
}
}
} catch (e: Exception) {
@@ -148,22 +236,24 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
}
}
fun Destination.destinationPublicKey(): String = when (this) {
is Destination.Contact -> publicKey
is Destination.ClosedGroup -> groupPublicKey
else -> throw NullPointerException("Not public key for this destination")
}
fun Destination.destinationPublicKey(): String =
when (this) {
is Destination.Contact -> publicKey
is Destination.ClosedGroup -> publicKey
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
}
val (type, address) =
when (destination) {
is Destination.Contact -> CONTACT_TYPE to destination.publicKey
is Destination.ClosedGroup -> GROUP_TYPE to destination.publicKey
else -> return Data.EMPTY
}
return Data.Builder()
.putInt(DESTINATION_TYPE_KEY, type)
.putString(DESTINATION_ADDRESS_KEY, address)
.build()
.putInt(DESTINATION_TYPE_KEY, type)
.putString(DESTINATION_ADDRESS_KEY, address)
.build()
}
override fun getFactoryKey(): String = KEY
@@ -179,20 +269,68 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
// type mappings
const val CONTACT_TYPE = 1
const val GROUP_TYPE = 2
fun ConfigBase.messageInformation(toDelete: MutableList<String>,
auth: SwarmAuth): ConfigMessageInformation {
val sentTimestamp = SnodeAPI.nowWithOffset
val (push, seqNo, obsoleteHashes) = push()
toDelete.addAll(obsoleteHashes)
val message =
SnodeMessage(
auth.accountId.hexString,
Base64.encodeBytes(push),
SnodeMessage.CONFIG_TTL,
sentTimestamp
)
return ConfigMessageInformation(
SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace(),
message,
auth,
),
this,
seqNo
)
}
fun GroupKeysConfig.messageInformation(auth: OwnedSwarmAuth): ConfigMessageInformation {
val sentTimestamp = SnodeAPI.nowWithOffset
val message =
SnodeMessage(
auth.accountId.hexString,
Base64.encodeBytes(pendingConfig()!!), // should not be null from checking has pending
SnodeMessage.CONFIG_TTL,
sentTimestamp
)
return ConfigMessageInformation(
SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace(),
message,
auth,
),
this,
0
)
}
}
class Factory: Job.Factory<ConfigurationSyncJob> {
class Factory : Job.Factory<ConfigurationSyncJob> {
override fun create(data: Data): ConfigurationSyncJob? {
if (!data.hasInt(DESTINATION_TYPE_KEY) || !data.hasString(DESTINATION_ADDRESS_KEY)) return null
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
}
val destination =
when (data.getInt(DESTINATION_TYPE_KEY)) {
CONTACT_TYPE -> Destination.Contact(address)
GROUP_TYPE -> Destination.ClosedGroup(address)
else -> return null
}
return ConfigurationSyncJob(destination)
}
}
}
}

View File

@@ -0,0 +1,108 @@
package org.session.libsession.messaging.jobs
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
import org.session.libsession.messaging.sending_receiving.MessageReceiver
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.disableLocalGroupAndUnsubscribe
import org.session.libsession.messaging.utilities.Data
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.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.Log
class GroupLeavingJob(val groupPublicKey: String, val notifyUser: Boolean, val deleteThread: Boolean): Job {
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
override val maxFailureCount: Int = 0
companion object {
val TAG = GroupLeavingJob::class.simpleName
val KEY: String = "GroupLeavingJob"
// Keys used for database storage
private val GROUP_PUBLIC_KEY_KEY = "group_public_key"
private val NOTIFY_USER_KEY = "notify_user"
private val DELETE_THREAD_KEY = "delete_thread"
}
override suspend fun execute(dispatcherName: String) {
val context = MessagingModuleConfiguration.shared.context
val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
val group = storage.getGroup(groupID) ?: return handlePermanentFailure(dispatcherName, MessageSender.Error.NoThread)
val updatedMembers = group.members.map { it.serialize() }.toSet() - userPublicKey
val admins = group.admins.map { it.serialize() }
val name = group.title
// Send the update to the group
val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft())
val sentTime = SnodeAPI.nowWithOffset
closedGroupControlMessage.sentTimestamp = sentTime
storage.setActive(groupID, false)
var messageId: Long? = null
val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
if (notifyUser) {
val infoType = SignalServiceGroup.Type.LEAVING
messageId = storage.insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime)
}
MessageSender.sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID), false).success {
// Notify the user
if (notifyUser && (messageId != null)) {
val infoType = SignalServiceGroup.Type.QUIT
storage.updateInfoMessage(context, messageId, groupID, infoType, name, updatedMembers)
}
// Remove the group private key and unsubscribe from PNs
MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, deleteThread)
handleSuccess(dispatcherName)
}.fail {
storage.setActive(groupID, true)
if (notifyUser && (messageId != null)) {
val infoType = SignalServiceGroup.Type.ERROR_QUIT
storage.updateInfoMessage(context, messageId, groupID, infoType, name, updatedMembers)
}
handleFailure(dispatcherName, it)
}
}
private fun handleSuccess(dispatcherName: String) {
Log.w(TAG, "Group left successfully.")
delegate?.handleJobSucceeded(this, dispatcherName)
}
private fun handlePermanentFailure(dispatcherName: String, e: Exception) {
delegate?.handleJobFailedPermanently(this, dispatcherName, e)
}
private fun handleFailure(dispatcherName: String, e: Exception) {
delegate?.handleJobFailed(this, dispatcherName, e)
}
override fun serialize(): Data {
return Data.Builder()
.putString(GROUP_PUBLIC_KEY_KEY, groupPublicKey)
.putBoolean(NOTIFY_USER_KEY, notifyUser)
.putBoolean(DELETE_THREAD_KEY, deleteThread)
.build()
}
override fun getFactoryKey(): String {
return KEY
}
class Factory : Job.Factory<GroupLeavingJob> {
override fun create(data: Data): GroupLeavingJob {
return GroupLeavingJob(
data.getString(GROUP_PUBLIC_KEY_KEY),
data.getBoolean(NOTIFY_USER_KEY),
data.getBoolean(DELETE_THREAD_KEY)
)
}
}
}

View File

@@ -0,0 +1,199 @@
package org.session.libsession.messaging.jobs
import android.widget.Toast
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import org.session.libsession.R
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.prettifiedDescription
class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array<String>) : Job {
companion object {
const val KEY = "InviteContactJob"
private const val GROUP = "group"
private const val MEMBER = "member"
}
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
override val maxFailureCount: Int = 1
override suspend fun execute(dispatcherName: String) {
val delegate = delegate ?: return
val configs = MessagingModuleConfiguration.shared.configFactory
val adminKey = configs.userGroups?.getClosedGroup(groupSessionId)?.adminKey
?: return delegate.handleJobFailedPermanently(
this,
dispatcherName,
NullPointerException("No admin key")
)
withContext(Dispatchers.IO) {
val sessionId = AccountId(groupSessionId)
val members = configs.getGroupMemberConfig(sessionId)
val info = configs.getGroupInfoConfig(sessionId)
val keys = configs.getGroupKeysConfig(sessionId, info, members, free = false)
if (members == null || info == null || keys == null) {
return@withContext delegate.handleJobFailedPermanently(
this@InviteContactsJob,
dispatcherName,
NullPointerException("One of the group configs was null")
)
}
val requests = memberSessionIds.map { memberSessionId ->
async {
// Make the request for this member
val member = members.get(memberSessionId) ?: return@async run {
InviteResult.failure(
memberSessionId,
NullPointerException("No group member ${memberSessionId.prettifiedDescription()} in members config")
)
}
members.set(member.setInvited())
configs.saveGroupConfigs(keys, info, members)
val accountId = AccountId(memberSessionId)
val subAccount = keys.makeSubAccount(accountId)
val timestamp = SnodeAPI.nowWithOffset
val signature = SodiumUtilities.sign(
buildGroupInviteSignature(accountId, timestamp),
adminKey
)
val groupInvite = GroupUpdateInviteMessage.newBuilder()
.setGroupSessionId(groupSessionId)
.setMemberAuthData(ByteString.copyFrom(subAccount))
.setAdminSignature(ByteString.copyFrom(signature))
.setName(info.getName())
val message = GroupUpdateMessage.newBuilder()
.setInviteMessage(groupInvite)
.build()
val update = GroupUpdated(message).apply {
sentTimestamp = timestamp
}
try {
MessageSender.send(update, Destination.Contact(memberSessionId), false)
.get()
InviteResult.success(memberSessionId)
} catch (e: Exception) {
InviteResult.failure(memberSessionId, e)
}
}
}
val results = requests.awaitAll()
results.forEach { result ->
if (!result.success) {
// update invite failed
val toSet = members.get(result.memberSessionId)
?.setInviteFailed()
?: return@forEach
members.set(toSet)
}
}
val failures = results.filter { !it.success }
// if there are failed invites, display a message
// assume job "success" even if we fail, the state of invites is tracked outside of this job
if (failures.isNotEmpty()) {
// show the failure toast
val storage = MessagingModuleConfiguration.shared.storage
val toaster = MessagingModuleConfiguration.shared.toaster
when (failures.size) {
1 -> {
val first = failures.first()
val firstString = first.memberSessionId.let { storage.getContactWithAccountID(it) }?.name
?: truncateIdForDisplay(first.memberSessionId)
withContext(Dispatchers.Main) {
toaster.toast(R.string.groupInviteFailedUser, Toast.LENGTH_LONG,
mapOf(
NAME_KEY to firstString,
GROUP_NAME_KEY to info.getName()
)
)
}
}
2 -> {
val (first, second) = failures
val firstString = first.memberSessionId.let { storage.getContactWithAccountID(it) }?.name
?: truncateIdForDisplay(first.memberSessionId)
val secondString = second.memberSessionId.let { storage.getContactWithAccountID(it) }?.name
?: truncateIdForDisplay(second.memberSessionId)
withContext(Dispatchers.Main) {
toaster.toast(R.string.groupInviteFailedTwo, Toast.LENGTH_LONG,
mapOf(
NAME_KEY to firstString,
OTHER_NAME_KEY to secondString,
GROUP_NAME_KEY to info.getName()
)
)
}
}
else -> {
val first = failures.first()
val firstString = first.memberSessionId.let { storage.getContactWithAccountID(it) }?.name
?: truncateIdForDisplay(first.memberSessionId)
val remaining = failures.size - 1
withContext(Dispatchers.Main) {
toaster.toast(R.string.groupInviteFailedMultiple, Toast.LENGTH_LONG,
mapOf(
NAME_KEY to firstString,
OTHER_NAME_KEY to remaining.toString(),
GROUP_NAME_KEY to info.getName()
)
)
}
}
}
}
configs.saveGroupConfigs(keys, info, members)
keys.free()
info.free()
members.free()
}
}
@Suppress("DataClassPrivateConstructor")
data class InviteResult private constructor(
val memberSessionId: String,
val success: Boolean,
val error: Exception? = null
) {
companion object {
fun success(memberSessionId: String) = InviteResult(memberSessionId, success = true)
fun failure(memberSessionId: String, error: Exception) =
InviteResult(memberSessionId, success = false, error)
}
}
override fun serialize(): Data =
Data.Builder()
.putString(GROUP, groupSessionId)
.putStringArray(MEMBER, memberSessionIds)
.build()
override fun getFactoryKey(): String = KEY
}

View File

@@ -24,10 +24,13 @@ import kotlin.math.roundToLong
class JobQueue : JobDelegate {
private var hasResumedPendingJobs = false // Just for debugging
private val jobTimestampMap = ConcurrentHashMap<Long, AtomicInteger>()
private val rxDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val rxMediaDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
private val openGroupDispatcher = Executors.newFixedThreadPool(8).asCoroutineDispatcher()
private val txDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val configDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val scope = CoroutineScope(Dispatchers.Default) + SupervisorJob()
private val queue = Channel<Job>(UNLIMITED)
private val pendingJobIds = mutableSetOf<String>()
@@ -114,15 +117,25 @@ class JobQueue : JobDelegate {
val txQueue = Channel<Job>(capacity = UNLIMITED)
val mediaQueue = Channel<Job>(capacity = UNLIMITED)
val openGroupQueue = Channel<Job>(capacity = UNLIMITED)
val configQueue = Channel<Job>(capacity = UNLIMITED)
val receiveJob = processWithDispatcher(rxQueue, rxDispatcher, "rx", asynchronous = false)
val txJob = processWithDispatcher(txQueue, txDispatcher, "tx")
val mediaJob = processWithDispatcher(mediaQueue, rxMediaDispatcher, "media")
val openGroupJob = processWithOpenGroupDispatcher(openGroupQueue, openGroupDispatcher, "openGroup")
val configJob = processWithDispatcher(configQueue, configDispatcher, "configDispatcher")
while (isActive) {
when (val job = queue.receive()) {
is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob, is ConfigurationSyncJob -> {
is InviteContactsJob,
is ConfigurationSyncJob -> {
configQueue.send(job)
}
is NotifyPNServerJob,
is AttachmentUploadJob,
is GroupLeavingJob,
is LibSessionGroupLeavingJob,
is MessageSendJob -> {
txQueue.send(job)
}
is RetrieveProfileAvatarJob,
@@ -154,11 +167,11 @@ class JobQueue : JobDelegate {
txJob.cancel()
mediaJob.cancel()
openGroupJob.cancel()
configJob.cancel()
}
}
companion object {
@JvmStatic
val shared: JobQueue by lazy { JobQueue() }
}
@@ -227,6 +240,9 @@ class JobQueue : JobDelegate {
OpenGroupDeleteJob.KEY,
RetrieveProfileAvatarJob.KEY,
ConfigurationSyncJob.KEY,
InviteContactsJob.KEY,
GroupLeavingJob.KEY,
LibSessionGroupLeavingJob.KEY
)
allJobTypes.forEach { type ->
resumePendingJobs(type)

View File

@@ -0,0 +1,63 @@
package org.session.libsession.messaging.jobs
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsignal.utilities.AccountId
class LibSessionGroupLeavingJob(val accountId: AccountId, val deleteOnLeave: Boolean): Job {
override var delegate: JobDelegate? = null
override var id: String? = null
override var failureCount: Int = 0
override val maxFailureCount: Int = 4
override suspend fun execute(dispatcherName: String) {
val storage = MessagingModuleConfiguration.shared.storage
// start leaving
// create message ID with leaving state
val messageId = storage.insertGroupInfoLeaving(accountId) ?: run {
delegate?.handleJobFailedPermanently(
this,
dispatcherName,
Exception("Couldn't insert GroupInfoLeaving message in leaving group job")
)
return
}
// do actual group leave request
// on success
if (storage.leaveGroup(accountId.hexString, deleteOnLeave)) {
// message is already deleted, succeed
delegate?.handleJobSucceeded(this, dispatcherName)
} else {
// Error leaving group, update the info message
storage.updateGroupInfoChange(messageId, UpdateMessageData.Kind.GroupErrorQuit)
}
}
override fun serialize(): Data =
Data.Builder()
.putString(SESSION_ID_KEY, accountId.hexString)
.putBoolean(DELETE_ON_LEAVE_KEY, deleteOnLeave)
.build()
class Factory : Job.Factory<LibSessionGroupLeavingJob> {
override fun create(data: Data): LibSessionGroupLeavingJob {
return LibSessionGroupLeavingJob(
AccountId(data.getString(SESSION_ID_KEY)),
data.getBoolean(DELETE_ON_LEAVE_KEY)
)
}
}
override fun getFactoryKey(): String = KEY
companion object {
const val KEY = "LibSessionGroupLeavingJob"
private const val SESSION_ID_KEY = "SessionId"
private const val DELETE_ON_LEAVE_KEY = "DeleteOnLeave"
}
}

View File

@@ -41,7 +41,7 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val
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, threadId ?: -1, this.openGroupID)
MessageReceiver.handle(message, proto, threadId ?: -1, this.openGroupID, null)
this.handleSuccess(dispatcherName)
deferred.resolve(Unit)
} catch (e: Exception) {

View File

@@ -10,12 +10,15 @@ sealed class Destination {
data class Contact(var publicKey: String) : Destination() {
internal constructor(): this("")
}
data class ClosedGroup(var groupPublicKey: String) : Destination() {
data class LegacyClosedGroup(var groupPublicKey: String) : Destination() {
internal constructor(): this("")
}
data class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() {
internal constructor(): this("", "")
}
data class ClosedGroup(var publicKey: String): Destination() {
internal constructor(): this("")
}
class OpenGroup(
var roomToken: String = "",
@@ -38,10 +41,10 @@ sealed class Destination {
address.isContact -> {
Contact(address.contactIdentifier())
}
address.isClosedGroup -> {
address.isLegacyClosedGroup -> {
val groupID = address.toGroupString()
val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString()
ClosedGroup(groupPublicKey)
LegacyClosedGroup(groupPublicKey)
}
address.isCommunity -> {
val storage = MessagingModuleConfiguration.shared.storage
@@ -58,6 +61,9 @@ sealed class Destination {
groupInboxId.last()
)
}
address.isClosedGroupV2 -> {
ClosedGroup(address.serialize())
}
else -> {
throw Exception("TODO: Handle legacy closed groups.")
}

View File

@@ -1,12 +1,10 @@
package org.session.libsession.messaging.messages
import com.google.protobuf.ByteString
import network.loki.messenger.libsession_util.util.ExpiryMode
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.visible.VisibleMessage
import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType
@@ -50,12 +48,7 @@ abstract class Message {
abstract fun toProto(): SignalServiceProtos.Content?
fun SignalServiceProtos.DataMessage.Builder.setGroupContext() {
group = SignalServiceProtos.GroupContext.newBuilder().apply {
id = GroupUtil.doubleEncodeGroupID(recipient!!).let(GroupUtil::getDecodedGroupIDAsData).let(ByteString::copyFrom)
type = SignalServiceProtos.GroupContext.Type.DELIVER
}.build()
}
abstract fun shouldDiscardIfBlocked(): Boolean
fun SignalServiceProtos.Content.Builder.applyExpiryMode() = apply {
expirationTimer = expiryMode.expirySeconds.toInt()

View File

@@ -3,9 +3,12 @@ package org.session.libsession.messaging.messages.control
import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.copyExpiration
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.*
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ANSWER
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.END_CALL
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.OFFER
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PRE_OFFER
import org.session.libsignal.utilities.Log
import java.util.*
import java.util.UUID
class CallMessage(): ControlMessage() {
var type: SignalServiceProtos.CallMessage.Type? = null
@@ -14,6 +17,8 @@ class CallMessage(): ControlMessage() {
var sdpMids: List<String> = listOf()
var callId: UUID? = null
override fun shouldDiscardIfBlocked(): Boolean = true
override val coerceDisappearAfterSendToRead = true
override val isSelfSendValid: Boolean get() = type in arrayOf(ANSWER, END_CALL)

View File

@@ -28,6 +28,8 @@ class ClosedGroupControlMessage() : ControlMessage() {
}
}
override fun shouldDiscardIfBlocked(): Boolean = kind !is Kind.EncryptionKeyPair
override val isSelfSendValid: Boolean = true
override fun isValid(): Boolean {
@@ -154,7 +156,6 @@ class ClosedGroupControlMessage() : ControlMessage() {
return SignalServiceProtos.Content.newBuilder().apply {
dataMessage = DataMessage.newBuilder().also {
it.closedGroupControlMessage = closedGroupControlMessage.build()
it.setGroupContext()
}.build()
}.build()
} catch (e: Exception) {

View File

@@ -20,6 +20,8 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
override val isSelfSendValid: Boolean = true
override fun shouldDiscardIfBlocked(): Boolean = true
class ClosedGroup(var publicKey: String, var name: String, var encryptionKeyPair: ECKeyPair?, var members: List<String>, var admins: List<String>) {
val isValid: Boolean get() = members.isNotEmpty() && admins.isNotEmpty()
@@ -122,7 +124,7 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
val profileKey = ProfileKeyUtil.getProfileKey(context)
val groups = storage.getAllGroups(includeInactive = false)
for (group in groups) {
if (group.isClosedGroup && group.isActive) {
if (group.isLegacyClosedGroup && group.isActive) {
if (!group.members.contains(Address.fromSerialized(storage.getUserPublicKey()!!))) continue
val groupPublicKey = GroupUtil.doubleDecodeGroupID(group.encodedId).toHexString()
val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: continue

View File

@@ -20,6 +20,8 @@ class DataExtractionNotification() : ControlMessage() {
}
}
override fun shouldDiscardIfBlocked(): Boolean = true
companion object {
const val TAG = "DataExtractionNotification"

View File

@@ -13,13 +13,15 @@ import org.session.libsignal.utilities.Log
data class ExpirationTimerUpdate(var syncTarget: String? = null, val isGroup: Boolean = false) : ControlMessage() {
override val isSelfSendValid: Boolean = true
override fun shouldDiscardIfBlocked(): Boolean = true
companion object {
const val TAG = "ExpirationTimerUpdate"
private val storage = MessagingModuleConfiguration.shared.storage
fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? =
fun fromProto(proto: SignalServiceProtos.Content, isGroup: Boolean): ExpirationTimerUpdate? =
proto.dataMessage?.takeIf { it.flags and EXPIRATION_TIMER_UPDATE_VALUE != 0 }?.run {
ExpirationTimerUpdate(takeIf { hasSyncTarget() }?.syncTarget, hasGroup()).copyExpiration(proto)
ExpirationTimerUpdate(takeIf { hasSyncTarget() }?.syncTarget, isGroup).copyExpiration(proto)
}
}
@@ -30,15 +32,6 @@ data class ExpirationTimerUpdate(var syncTarget: String? = null, val isGroup: Bo
}
// Sync target
syncTarget?.let { dataMessageProto.syncTarget = it }
// Group context
if (storage.isClosedGroup(recipient!!)) {
try {
dataMessageProto.setGroupContext()
} catch(e: Exception) {
Log.w(TAG, "Couldn't construct visible message proto from: $this", e)
return null
}
}
return try {
SignalServiceProtos.Content.newBuilder()
.setDataMessage(dataMessageProto)

View File

@@ -0,0 +1,35 @@
package org.session.libsession.messaging.messages.control
import org.session.libsignal.protos.SignalServiceProtos.Content
import org.session.libsignal.protos.SignalServiceProtos.DataMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
class GroupUpdated(val inner: GroupUpdateMessage): ControlMessage() {
override fun isValid(): Boolean {
return true // TODO: add the validation here
}
override val isSelfSendValid: Boolean = true
override fun shouldDiscardIfBlocked(): Boolean =
!inner.hasPromoteMessage() && !inner.hasInfoChangeMessage()
&& !inner.hasMemberChangeMessage() && !inner.hasMemberLeftMessage()
&& !inner.hasInviteResponse() && !inner.hasDeleteMemberContent()
companion object {
fun fromProto(message: Content): GroupUpdated? =
if (message.hasDataMessage() && message.dataMessage.hasGroupUpdateMessage())
GroupUpdated(message.dataMessage.groupUpdateMessage)
else null
}
override fun toProto(): Content {
val dataMessage = DataMessage.newBuilder()
.setGroupUpdateMessage(inner)
.build()
return Content.newBuilder()
.setDataMessage(dataMessage)
.build()
}
}

View File

@@ -10,6 +10,8 @@ class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = nu
override val isSelfSendValid: Boolean = true
override fun shouldDiscardIfBlocked(): Boolean = true
override fun toProto(): SignalServiceProtos.Content? {
val profileProto = SignalServiceProtos.DataMessage.LokiProfile.newBuilder()
profile?.displayName?.let { profileProto.displayName = it }

View File

@@ -14,6 +14,8 @@ class ReadReceipt() : ControlMessage() {
return false
}
override fun shouldDiscardIfBlocked(): Boolean = true
companion object {
const val TAG = "ReadReceipt"

View File

@@ -8,6 +8,7 @@ class SharedConfigurationMessage(val kind: SharedConfigMessage.Kind, val data: B
override val ttl: Long = 30 * 24 * 60 * 60 * 1000L
override val isSelfSendValid: Boolean = true
override fun shouldDiscardIfBlocked(): Boolean = true // should only be called with our own user which shouldn't be blocked...
companion object {
fun fromProto(proto: SignalServiceProtos.Content): SharedConfigurationMessage? =

View File

@@ -14,6 +14,8 @@ class TypingIndicator() : ControlMessage() {
return kind != null
}
override fun shouldDiscardIfBlocked(): Boolean = true
companion object {
const val TAG = "TypingIndicator"

View File

@@ -8,6 +8,8 @@ class UnsendRequest(var timestamp: Long? = null, var author: String? = null): Co
override val isSelfSendValid: Boolean = true
override fun shouldDiscardIfBlocked(): Boolean = true // current behavior, not sure if should be true
// region Validation
override fun isValid(): Boolean {
if (!super.isValid()) return false

View File

@@ -2,12 +2,10 @@ package org.session.libsession.messaging.messages.signal;
public class IncomingGroupMessage extends IncomingTextMessage {
private final String groupID;
private final boolean updateMessage;
public IncomingGroupMessage(IncomingTextMessage base, String groupID, String body, boolean updateMessage) {
public IncomingGroupMessage(IncomingTextMessage base, String body, boolean updateMessage) {
super(base, body);
this.groupID = groupID;
this.updateMessage = updateMessage;
}

View File

@@ -4,14 +4,15 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment;
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage;
import org.session.libsession.utilities.Contact;
import org.session.libsession.utilities.Address;
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Contact;
import org.session.libsession.utilities.GroupUtil;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsignal.messages.SignalServiceAttachment;
import org.session.libsignal.messages.SignalServiceGroup;
import org.session.libsignal.utilities.Hex;
import org.session.libsignal.utilities.guava.Optional;
import java.util.Collections;
import java.util.LinkedList;
@@ -70,8 +71,18 @@ public class IncomingMediaMessage {
this.messageRequestResponse = messageRequestResponse;
this.hasMention = hasMention;
if (group.isPresent()) this.groupId = Address.fromSerialized(GroupUtil.INSTANCE.getEncodedId(group.get()));
else this.groupId = null;
if (group.isPresent()) {
SignalServiceGroup groupObject = group.get();
if (groupObject.isNewClosedGroup()) {
// new closed group 03..etc..
this.groupId = Address.fromSerialized(Hex.toStringCondensed(groupObject.getGroupId()));
} else {
// old closed group or open group
this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get()));
}
} else {
this.groupId = null;
}
this.attachments.addAll(PointerAttachment.forPointers(attachments));
this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList()));

View File

@@ -8,11 +8,12 @@ import androidx.annotation.Nullable;
import org.session.libsession.messaging.calls.CallMessageType;
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation;
import org.session.libsession.messaging.messages.visible.VisibleMessage;
import org.session.libsession.utilities.Address;
import org.session.libsession.messaging.utilities.UpdateMessageData;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.GroupUtil;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsignal.messages.SignalServiceGroup;
import org.session.libsignal.utilities.Hex;
import org.session.libsignal.utilities.guava.Optional;
public class IncomingTextMessage implements Parcelable {
@@ -80,7 +81,14 @@ public class IncomingTextMessage implements Parcelable {
this.hasMention = hasMention;
if (group.isPresent()) {
this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get()));
SignalServiceGroup groupObject = group.get();
if (groupObject.isNewClosedGroup()) {
// new closed group 03..etc..
this.groupId = Address.fromSerialized(Hex.toStringCondensed(groupObject.getGroupId()));
} else {
// old closed group or open group
this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get()));
}
} else {
this.groupId = null;
}

View File

@@ -5,6 +5,9 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.copyExpiration
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Log
import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment
@@ -29,6 +32,8 @@ data class VisibleMessage(
override val isSelfSendValid: Boolean = true
override fun shouldDiscardIfBlocked(): Boolean = true
// region Validation
override fun isValid(): Boolean {
if (!super.isValid()) return false
@@ -101,14 +106,13 @@ data class VisibleMessage(
proto.applyExpiryMode()
// Group context
val storage = MessagingModuleConfiguration.shared.storage
if (storage.isClosedGroup(recipient!!)) {
try {
dataMessage.setGroupContext()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct visible message proto from: $this")
return null
}
val context = MessagingModuleConfiguration.shared.context
val expiration = if (storage.isLegacyClosedGroup(recipient!!)) {
Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(recipient!!)), false).expireMessages
} else {
Recipient.from(context, Address.fromSerialized(recipient!!), false).expireMessages
}
dataMessage.expireTimer = expiration
// Community blocked message requests flag
dataMessage.blocksCommunityMessageRequests = blocksMessageRequests
// Sync target

View File

@@ -0,0 +1,15 @@
package org.session.libsession.messaging.notifications
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
interface TokenFetcher {
suspend fun fetch(): String {
return token.filterNotNull().first()
}
val token: StateFlow<String?>
fun onNewToken(token: String)
}

View File

@@ -17,13 +17,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.maxInactivityPeriod
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.OnionResponse
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64.decode
import org.session.libsignal.utilities.Base64.encodeBytes
import org.session.libsignal.utilities.HTTP
@@ -312,11 +312,10 @@ object OpenGroupApi {
val publicKey =
MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server)
?: return Promise.ofFail(Error.NoPublicKey)
val ed25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()
?: return Promise.ofFail(Error.NoEd25519KeyPair)
val urlRequest = urlBuilder.toString()
val headers = request.headers.toMutableMap()
val nonce = sodium.nonce(16)
val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset)
var pubKey = ""

View File

@@ -50,7 +50,7 @@ data class OpenGroupMessage(
fun sign(room: String, server: String, fallbackSigningType: IdPrefix): OpenGroupMessage? {
if (base64EncodedData.isNullOrEmpty()) return null
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null
val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: return null
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(room, server) ?: return null
val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server)
val signature = when {

View File

@@ -1,15 +1,13 @@
package org.session.libsession.messaging.sending_receiving
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.Box
import com.goterl.lazysodium.interfaces.Sign
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
@@ -69,7 +67,7 @@ object MessageDecrypter {
serverPublicKey: String
): Pair<ByteArray, String> {
if (message.size < Box.NONCEBYTES + 2) throw Error.DecryptionFailed
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.DecryptionFailed
// Calculate the shared encryption key, receiving from A to B
val otherKeyBytes = Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded())

View File

@@ -24,7 +24,7 @@ object MessageEncrypter {
* @return the encrypted message.
*/
internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray {
val userED25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val userED25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded())
val verificationData = plaintext + userED25519KeyPair.publicKey.asBytes + recipientX25519PublicKey
@@ -54,7 +54,7 @@ object MessageEncrypter {
): ByteArray {
if (IdPrefix.fromValue(recipientBlindedId) != IdPrefix.BLINDED) throw Error.SigningFailed
val userEdKeyPair =
MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair
val blindedKeyPair = SodiumUtilities.blindedKeyPair(serverPublicKey, userEdKeyPair) ?: throw Error.SigningFailed
val recipientBlindedPublicKey = Hex.fromStringCondensed(recipientBlindedId.removingIdPrefixIfNeeded())

View File

@@ -7,17 +7,19 @@ import org.session.libsession.messaging.messages.control.ClosedGroupControlMessa
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.GroupUpdated
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
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.crypto.PushTransportDetails
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.Envelope
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
@@ -55,22 +57,24 @@ object MessageReceiver {
isOutgoing: Boolean? = null,
otherBlindedPublicKey: String? = null,
openGroupPublicKey: String? = null,
currentClosedGroups: Set<String>?
currentClosedGroups: Set<String>?,
closedGroupSessionId: String? = null,
): Pair<Message, SignalServiceProtos.Content> {
val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = storage.getUserPublicKey()
val isOpenGroupMessage = (openGroupServerID != null)
// Parse the envelope
val envelope = SignalServiceProtos.Envelope.parseFrom(data)
// Decrypt the contents
val ciphertext = envelope.content ?: run {
throw Error.NoData
}
var plaintext: ByteArray? = null
var sender: String? = null
var groupPublicKey: String? = null
// Parse the envelope
val envelope = Envelope.parseFrom(data) ?: throw Error.InvalidMessage
// Decrypt the contents
val envelopeContent = envelope.content ?: run {
throw Error.NoData
}
if (isOpenGroupMessage) {
plaintext = envelope.content.toByteArray()
plaintext = envelopeContent.toByteArray()
sender = envelope.source
} else {
when (envelope.type) {
@@ -79,7 +83,7 @@ object MessageReceiver {
openGroupPublicKey ?: throw Error.InvalidGroupPublicKey
otherBlindedPublicKey ?: throw Error.DecryptionFailed
val decryptionResult = MessageDecrypter.decryptBlinded(
ciphertext.toByteArray(),
envelopeContent.toByteArray(),
isOutgoing ?: false,
otherBlindedPublicKey,
openGroupPublicKey
@@ -88,50 +92,53 @@ object MessageReceiver {
sender = decryptionResult.second
} else {
val userX25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair()
val decryptionResult = MessageDecrypter.decrypt(ciphertext.toByteArray(), userX25519KeyPair)
val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), userX25519KeyPair)
plaintext = decryptionResult.first
sender = decryptionResult.second
}
}
SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE -> {
val hexEncodedGroupPublicKey = envelope.source
if (hexEncodedGroupPublicKey == null || !MessagingModuleConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey)) {
throw Error.InvalidGroupPublicKey
}
val encryptionKeyPairs = MessagingModuleConfiguration.shared.storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey)
if (encryptionKeyPairs.isEmpty()) {
throw Error.NoGroupKeyPair
}
// Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than
// likely be the one we want) but try older ones in case that didn't work)
var encryptionKeyPair = encryptionKeyPairs.removeLast()
fun decrypt() {
try {
val decryptionResult = MessageDecrypter.decrypt(ciphertext.toByteArray(), encryptionKeyPair)
plaintext = decryptionResult.first
sender = decryptionResult.second
} catch (e: Exception) {
if (encryptionKeyPairs.isNotEmpty()) {
encryptionKeyPair = encryptionKeyPairs.removeLast()
decrypt()
} else {
Log.e("Loki", "Failed to decrypt group message", e)
throw e
val hexEncodedGroupPublicKey = closedGroupSessionId ?: envelope.source
val sessionId = AccountId(hexEncodedGroupPublicKey)
if (sessionId.prefix == IdPrefix.GROUP) {
plaintext = envelopeContent.toByteArray()
sender = envelope.source
groupPublicKey = hexEncodedGroupPublicKey
} else {
if (!MessagingModuleConfiguration.shared.storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) {
throw Error.InvalidGroupPublicKey
}
val encryptionKeyPairs = MessagingModuleConfiguration.shared.storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey)
if (encryptionKeyPairs.isEmpty()) {
throw Error.NoGroupKeyPair
}
// Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than
// likely be the one we want) but try older ones in case that didn't work)
var encryptionKeyPair = encryptionKeyPairs.removeLast()
fun decrypt() {
try {
val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), encryptionKeyPair)
plaintext = decryptionResult.first
sender = decryptionResult.second
} catch (e: Exception) {
if (encryptionKeyPairs.isNotEmpty()) {
encryptionKeyPair = encryptionKeyPairs.removeLast()
decrypt()
} else {
Log.e("Loki", "Failed to decrypt group message", e)
throw e
}
}
}
groupPublicKey = hexEncodedGroupPublicKey
decrypt()
}
groupPublicKey = envelope.source
decrypt()
}
else -> {
throw Error.UnknownEnvelopeType
}
}
}
// Don't process the envelope any further if the sender is blocked
if (isBlocked(sender!!)) {
throw Error.SenderBlocked
}
// Parse the proto
val proto = SignalServiceProtos.Content.parseFrom(PushTransportDetails.getStrippedPaddingMessageBody(plaintext))
// Parse the message
@@ -139,15 +146,19 @@ object MessageReceiver {
TypingIndicator.fromProto(proto) ?:
ClosedGroupControlMessage.fromProto(proto) ?:
DataExtractionNotification.fromProto(proto) ?:
ExpirationTimerUpdate.fromProto(proto) ?:
ExpirationTimerUpdate.fromProto(proto, closedGroupSessionId != null) ?:
ConfigurationMessage.fromProto(proto) ?:
UnsendRequest.fromProto(proto) ?:
MessageRequestResponse.fromProto(proto) ?:
CallMessage.fromProto(proto) ?:
SharedConfigurationMessage.fromProto(proto) ?:
GroupUpdated.fromProto(proto) ?:
VisibleMessage.fromProto(proto) ?: throw Error.UnknownMessage
val isUserBlindedSender = sender == openGroupPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
// Don't process the envelope any further if the sender is blocked
if (isBlocked(sender!!) && message.shouldDiscardIfBlocked()) {
throw Error.SenderBlocked
}
val isUserBlindedSender = sender == openGroupPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!) }?.let { AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
val isUserSender = sender == userPublicKey
if (isUserSender || isUserBlindedSender) {
@@ -175,7 +186,7 @@ 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 (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet())) {
if (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet()) && groupPublicKey?.startsWith(IdPrefix.GROUP.value) != true) {
throw Error.NoGroupThread
}
if ((message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) || message is SharedConfigurationMessage) {

View File

@@ -6,7 +6,6 @@ import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.jobs.NotifyPNServerJob
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.applyExpiryMode
@@ -14,6 +13,7 @@ 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.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.control.SharedConfigurationMessage
import org.session.libsession.messaging.messages.control.UnsendRequest
@@ -24,8 +24,9 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
import org.session.libsession.messaging.open_groups.OpenGroupMessage
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.snode.GroupSubAccountSwarmAuth
import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.snode.RawResponsePromise
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeAPI.nowWithOffset
@@ -37,6 +38,7 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsignal.crypto.PushTransportDetails
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
@@ -59,6 +61,7 @@ object MessageSender {
object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.")
object SigningFailed : Error("Couldn't sign message.")
object EncryptionFailed : Error("Couldn't encrypt message.")
data class InvalidDestination(val destination: Destination): Error("Can't send this way to $destination")
// Closed groups
object NoThread : Error("Couldn't find a thread associated with the given group public key.")
@@ -94,6 +97,7 @@ object MessageSender {
@Throws(Exception::class)
fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage {
val storage = MessagingModuleConfiguration.shared.storage
val configFactory = MessagingModuleConfiguration.shared.configFactory
val userPublicKey = storage.getUserPublicKey()
// Set the timestamp, sender and recipient
val messageSendTime = nowWithOffset
@@ -106,7 +110,8 @@ object MessageSender {
// SHARED CONFIG
when (destination) {
is Destination.Contact -> message.recipient = destination.publicKey
is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey
is Destination.LegacyClosedGroup -> message.recipient = destination.groupPublicKey
is Destination.ClosedGroup -> message.recipient = destination.publicKey
else -> throw IllegalStateException("Destination should not be an open group.")
}
@@ -139,22 +144,17 @@ object MessageSender {
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)
val proto = message.toProto()?.toBuilder() ?: throw Error.ProtoConversionFailed
if (message is GroupUpdated) {
// Add all cases where we have to attach profile
if (message.inner.hasInviteResponse()) {
proto.mergeDataMessage(storage.getUserProfile().toProto())
}
else -> throw IllegalStateException("Destination should not be open group.")
}
// Wrap the result
// Serialize the protobuf
val plaintext = PushTransportDetails.getPaddedMessageBody(proto.build().toByteArray())
// Envelope information
val kind: SignalServiceProtos.Envelope.Type
val senderPublicKey: String
when (destination) {
@@ -162,13 +162,47 @@ object MessageSender {
kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE
senderPublicKey = ""
}
is Destination.ClosedGroup -> {
is Destination.LegacyClosedGroup -> {
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE
senderPublicKey = destination.groupPublicKey
}
is Destination.ClosedGroup -> {
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE
senderPublicKey = destination.publicKey
}
else -> throw IllegalStateException("Destination should not be open group.")
}
val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
// Encrypt the serialized protobuf
val ciphertext = when (destination) {
is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey)
is Destination.LegacyClosedGroup -> {
val encryptionKeyPair =
MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(
destination.groupPublicKey
)!!
MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey)
}
is Destination.ClosedGroup -> {
val groupKeys = configFactory.getGroupKeysConfig(AccountId(destination.publicKey)) ?: throw Error.NoKeyPair
val envelope = MessageWrapper.createEnvelope(kind, message.sentTimestamp!!, senderPublicKey, proto.build().toByteArray())
groupKeys.use { keys ->
if (keys.keys().isEmpty()) {
throw Error.EncryptionFailed
}
keys.encrypt(envelope.toByteArray())
}
}
else -> throw IllegalStateException("Destination should not be open group.")
}
// Wrap the result using envelope information
val wrappedMessage = when (destination) {
is Destination.ClosedGroup -> {
// encrypted bytes from the above closed group encryption and envelope steps
ciphertext
}
else -> MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
}
val base64EncodedData = Base64.encodeBytes(wrappedMessage)
// Send the result
return SnodeMessage(
@@ -184,6 +218,7 @@ object MessageSender {
val deferred = deferred<Unit, Exception>()
val promise = deferred.promise
val storage = MessagingModuleConfiguration.shared.storage
val configFactory = MessagingModuleConfiguration.shared.configFactory
val userPublicKey = storage.getUserPublicKey()
// recipient will be set later, so initialize it as a function here
@@ -202,18 +237,46 @@ object MessageSender {
// TODO: this might change in future for config messages
val forkInfo = SnodeAPI.forkInfo
val namespaces: List<Int> = when {
destination is Destination.ClosedGroup
&& forkInfo.defaultRequiresAuth() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP)
destination is Destination.LegacyClosedGroup
&& forkInfo.defaultRequiresAuth() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP())
destination is Destination.ClosedGroup
destination is Destination.LegacyClosedGroup
&& forkInfo.hasNamespaces() -> listOf(
Namespace.UNAUTHENTICATED_CLOSED_GROUP,
Namespace.UNAUTHENTICATED_CLOSED_GROUP(),
Namespace.DEFAULT
)
())
destination is Destination.ClosedGroup -> listOf(Namespace.CLOSED_GROUP_MESSAGES())
else -> listOf(Namespace.DEFAULT)
else -> listOf(Namespace.DEFAULT())
}
namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises ->
namespaces.mapNotNull { namespace ->
if (destination is Destination.ClosedGroup) {
// possibly handle a failure for no user groups or no closed group signing key?
val group = configFactory.userGroups?.getClosedGroup(destination.publicKey) ?: return@mapNotNull null
val groupAuthData = group.authData
val groupAdminKey = group.adminKey
if (groupAuthData != null) {
configFactory.getGroupKeysConfig(AccountId(destination.publicKey))?.use { keys ->
SnodeAPI.sendMessage(
auth = GroupSubAccountSwarmAuth(keys, AccountId(destination.publicKey), groupAuthData),
message = snodeMessage,
namespace = namespace
)
}
} else if (groupAdminKey != null) {
SnodeAPI.sendMessage(
auth = OwnedSwarmAuth(AccountId(destination.publicKey), null, groupAdminKey),
message = snodeMessage,
namespace = namespace
)
} else {
Log.w("MessageSender", "No auth data for group")
null
}
} else {
SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = namespace)
}
}.let { promises ->
var isSuccess = false
val promiseCount = promises.size
val errorCount = AtomicInteger(0)
@@ -238,15 +301,6 @@ object MessageSender {
else -> false
}
/*
if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) {
shouldNotify = true
}
*/
if (shouldNotify) {
val notifyPNServerJob = NotifyPNServerJob(snodeMessage)
JobQueue.shared.add(notifyPNServerJob)
}
deferred.resolve(Unit)
}
promise.fail {
@@ -265,15 +319,30 @@ object MessageSender {
private fun getSpecifiedTtl(
message: Message,
isSyncMessage: Boolean
): Long? = message.takeUnless { it is ClosedGroupControlMessage }?.run {
threadID ?: (if (isSyncMessage && this is VisibleMessage) syncTarget else recipient)
?.let(Address.Companion::fromSerialized)
?.let(MessagingModuleConfiguration.shared.storage::getThreadId)
}?.let(MessagingModuleConfiguration.shared.storage::getExpirationConfiguration)
?.takeIf { it.isEnabled }
?.expiryMode
?.takeIf { it is ExpiryMode.AfterSend || isSyncMessage }
?.expiryMillis
): Long? {
// For ClosedGroupControlMessage or GroupUpdateMemberLeftMessage, the expiration timer doesn't apply
if (message is ClosedGroupControlMessage || (
message is GroupUpdated && (
message.inner.hasMemberLeftMessage() ||
message.inner.hasInviteMessage() ||
message.inner.hasInviteResponse() ||
message.inner.hasDeleteMemberContent() ||
message.inner.hasPromoteMessage()))) {
return null
}
// Otherwise the expiration configuration applies
return message.run {
threadID ?: (if (isSyncMessage && this is VisibleMessage) syncTarget else recipient)
?.let(Address.Companion::fromSerialized)
?.let(MessagingModuleConfiguration.shared.storage::getThreadId)
}
?.let(MessagingModuleConfiguration.shared.storage::getExpirationConfiguration)
?.takeIf { it.isEnabled }
?.expiryMode
?.takeIf { it is ExpiryMode.AfterSend || isSyncMessage }
?.expiryMillis
}
// Open Groups
private fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
@@ -289,7 +358,7 @@ object MessageSender {
message.blocksMessageRequests = !user.getCommunityMessageRequests()
}
}
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!
var serverCapabilities = listOf<String>()
var blindedPublicKey: ByteArray? = null
when(destination) {
@@ -381,7 +450,7 @@ object MessageSender {
}
// Result Handling
private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) {
fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) {
if (message is VisibleMessage) MessagingModuleConfiguration.shared.lastSentTimestampCache.submitTimestamp(message.threadID!!, openGroupSentTimestamp)
val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = storage.getUserPublicKey()!!
@@ -538,8 +607,8 @@ object MessageSender {
}
@JvmStatic
fun explicitLeave(groupPublicKey: String, notifyUser: Boolean): Promise<Unit, Exception> {
return leave(groupPublicKey, notifyUser)
fun explicitLeave(groupPublicKey: String, notifyUser: Boolean, deleteThread: Boolean = false) {
leave(groupPublicKey, notifyUser, deleteThread)
}
}

View File

@@ -6,10 +6,13 @@ import com.google.protobuf.ByteString
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.GroupLeavingJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
import org.session.libsession.messaging.sending_receiving.MessageSender.Error
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
@@ -98,7 +101,7 @@ fun MessageSender.create(
// Notify the PN server
PushRegistryV1.register(device = device, publicKey = userPublicKey)
// Start polling
ClosedGroupPollerV2.shared.startPolling(groupPublicKey)
LegacyClosedGroupPollerV2.shared.startPolling(groupPublicKey)
// Fulfill the promise
deferred.resolve(groupID)
}
@@ -238,37 +241,9 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St
}
}
fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true): Promise<Unit, Exception> {
val deferred = deferred<Unit, Exception>()
ThreadUtils.queue {
val context = MessagingModuleConfiguration.shared.context
val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
val group = storage.getGroup(groupID) ?: return@queue deferred.reject(Error.NoThread)
val updatedMembers = group.members.map { it.serialize() }.toSet() - userPublicKey
val admins = group.admins.map { it.serialize() }
val name = group.title
// Send the update to the group
val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft(), groupID)
val sentTime = SnodeAPI.nowWithOffset
closedGroupControlMessage.sentTimestamp = sentTime
storage.setActive(groupID, false)
sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID), isSyncMessage = false).success {
// Notify the user
val infoType = SignalServiceGroup.Type.MEMBER_LEFT
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, true)
deferred.resolve(Unit)
}.fail {
storage.setActive(groupID, true)
}
}
return deferred.promise
fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true, deleteThread: Boolean = false) {
val job = GroupLeavingJob(groupPublicKey, notifyUser, deleteThread)
JobQueue.shared.add(job)
}
fun MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey: String, targetMembers: Collection<String>) {

View File

@@ -1,10 +1,15 @@
package org.session.libsession.messaging.sending_receiving
import android.text.TextUtils
import network.loki.messenger.libsession_util.ConfigBase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.Sodium
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.messages.ExpirationConfiguration
@@ -15,6 +20,7 @@ import org.session.libsession.messaging.messages.control.ClosedGroupControlMessa
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.GroupUpdated
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.control.ReadReceipt
import org.session.libsession.messaging.messages.control.TypingIndicator
@@ -27,9 +33,12 @@ import org.session.libsession.messaging.sending_receiving.attachments.PointerAtt
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.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature
import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature
import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeVerifier
import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.WebRtcUtils
import org.session.libsession.snode.SnodeAPI
@@ -48,6 +57,7 @@ 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.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
@@ -55,6 +65,7 @@ import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.removingIdPrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import java.security.MessageDigest
import java.security.SignatureException
import java.util.LinkedList
import kotlin.math.min
@@ -64,7 +75,7 @@ internal fun MessageReceiver.isBlocked(publicKey: String): Boolean {
return recipient.isBlocked
}
fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, threadId: Long, openGroupID: String?) {
fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, threadId: Long, openGroupID: String?, closedGroup: AccountId?) {
// Do nothing if the message was outdated
if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return }
@@ -72,6 +83,7 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content,
is ReadReceipt -> handleReadReceipt(message)
is TypingIndicator -> handleTypingIndicator(message)
is ClosedGroupControlMessage -> handleClosedGroupControlMessage(message)
is GroupUpdated -> handleGroupUpdated(message, closedGroup)
is ExpirationTimerUpdate -> handleExpirationTimerUpdate(message)
is DataExtractionNotification -> handleDataExtractionNotification(message)
is ConfigurationMessage -> handleConfigurationMessage(message)
@@ -155,8 +167,7 @@ fun MessageReceiver.cancelTypingIndicatorsIfNeeded(senderPublicKey: String) {
private fun MessageReceiver.handleExpirationTimerUpdate(message: ExpirationTimerUpdate) {
SSKEnvironment.shared.messageExpirationManager.insertExpirationTimerMessage(message)
// TODO (Groups V2 - FIXME)
val isGroupV1 = message.groupPublicKey != null
val isGroupV1 = message.groupPublicKey != null && message.groupPublicKey?.startsWith(IdPrefix.GROUP.value) == false
if (isNewConfigEnabled && !isGroupV1) return
@@ -250,12 +261,13 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? {
if (message.sender != message.author && (message.sender != userPublicKey && userPublicKey != null)) { return null }
val context = MessagingModuleConfiguration.shared.context
val storage = MessagingModuleConfiguration.shared.storage
val userAuth = storage.userAuth ?: return null
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val timestamp = message.timestamp ?: return null
val author = message.author ?: return null
val (messageIdToDelete, mms) = storage.getMessageIdInDatabase(timestamp, author) ?: return null
messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { serverHash ->
SnodeAPI.deleteMessage(author, listOf(serverHash))
SnodeAPI.deleteMessage(author, swarmAuth = userAuth, listOf(serverHash))
}
val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author)
if (!messageDataProvider.isOutgoingMessage(timestamp)) {
@@ -304,7 +316,7 @@ fun MessageReceiver.handleVisibleMessage(
val threadRecipient = storage.getRecipientForThread(threadID)
val userBlindedKey = openGroupID?.let {
val openGroup = storage.getOpenGroup(threadID) ?: return@let null
val blindedKey = SodiumUtilities.blindedKeyPair(openGroup.publicKey, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) ?: return@let null
val blindedKey = SodiumUtilities.blindedKeyPair(openGroup.publicKey, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!) ?: return@let null
AccountId(
IdPrefix.BLINDED, blindedKey.publicKey.asBytes
).hexString
@@ -349,6 +361,14 @@ fun MessageReceiver.handleVisibleMessage(
disappearingState
)
}
// Handle group invite response if new closed group
if (threadRecipient?.isClosedGroupV2Recipient == true) {
storage.setGroupInviteCompleteIfNeeded(
approved = true,
recipient.address.serialize(),
AccountId(threadRecipient.address.serialize())
)
}
// Parse quote if needed
var quoteModel: QuoteModel? = null
var quoteMessageBody: String? = null
@@ -415,6 +435,16 @@ fun MessageReceiver.handleVisibleMessage(
// Persist the message
message.threadID = threadID
val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments, runThreadUpdate) ?: return null
// Parse & persist attachments
// Start attachment downloads if needed
if (threadRecipient?.autoDownloadAttachments == true || messageSender == userPublicKey) {
storage.getAttachmentsForMessage(messageID).iterator().forEach { attachment ->
attachment.attachmentId?.let { id ->
val downloadJob = AttachmentDownloadJob(id.rowId, messageID)
JobQueue.shared.add(downloadJob)
}
}
}
message.openGroupServerMessageID?.let {
val isSms = !message.isMediaMessage() && attachments.isEmpty()
storage.setOpenGroupServerMessageID(messageID, it, threadID, isSms)
@@ -437,7 +467,7 @@ fun MessageReceiver.handleOpenGroupReactions(
val userPublicKey = storage.getUserPublicKey()!!
val openGroup = storage.getOpenGroup(threadId)
val blindedPublicKey = openGroup?.publicKey?.let { serverPublicKey ->
SodiumUtilities.blindedKeyPair(serverPublicKey, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!)
SodiumUtilities.blindedKeyPair(serverPublicKey, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!)
?.let { AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
}
for ((emoji, reaction) in reactions) {
@@ -530,6 +560,167 @@ private fun ClosedGroupControlMessage.getPublicKey(): String = kind!!.let { when
is ClosedGroupControlMessage.Kind.NameChange -> groupPublicKey!!
}}
private fun MessageReceiver.handleGroupUpdated(message: GroupUpdated, closedGroup: AccountId?) {
val inner = message.inner
if (closedGroup == null &&
!inner.hasInviteMessage() && !inner.hasPromoteMessage()) {
throw NullPointerException("Message wasn't polled from a closed group!")
}
when {
inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message)
inner.hasInviteResponse() -> handleInviteResponse(message, closedGroup!!)
inner.hasPromoteMessage() -> handlePromotionMessage(message)
inner.hasInfoChangeMessage() -> handleGroupInfoChange(message, closedGroup!!)
inner.hasMemberChangeMessage() -> handleMemberChange(message, closedGroup!!)
inner.hasMemberLeftMessage() -> handleMemberLeft(message, closedGroup!!)
inner.hasMemberLeftNotificationMessage() -> handleMemberLeftNotification(message, closedGroup!!)
inner.hasDeleteMemberContent() -> handleDeleteMemberContent(message, closedGroup!!)
}
}
private fun handleDeleteMemberContent(message: GroupUpdated, closedGroup: AccountId) {
val storage = MessagingModuleConfiguration.shared.storage
val deleteMemberContent = message.inner.deleteMemberContent
val adminSig = if (deleteMemberContent.hasAdminSignature()) deleteMemberContent.adminSignature.toByteArray()!! else byteArrayOf()
val memberIds = deleteMemberContent.memberSessionIdsList
val hashes = deleteMemberContent.messageHashesList
val threadId = storage.getThreadId(Address.fromSerialized(closedGroup.hexString))!!
val messageToValidate = buildDeleteMemberContentSignature(
memberIds = memberIds.asSequence().map(::AccountId).asIterable(),
messageHashes = hashes,
timestamp = message.sentTimestamp!!
)
if (hashes.isNotEmpty()) {
// Delete all hashes conditionally
if (storage.ensureMessageHashesAreSender(hashes.toSet(), message.sender!!, closedGroup.hexString)) {
// ensure that all message hashes belong to user
// storage delete
storage.deleteMessagesByHash(threadId, hashes)
} else {
// otherwise assert a valid admin sig exists
verifyAdminSignature(
closedGroup,
adminSig,
messageToValidate
)
// storage delete
storage.deleteMessagesByHash(threadId, hashes)
}
} else if (memberIds.isNotEmpty()) {
// Delete all from member Ids, and require admin sig?
verifyAdminSignature(
closedGroup,
adminSig,
messageToValidate
)
for (member in memberIds) {
storage.deleteMessagesByUser(threadId, member)
}
}
}
private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) {
val storage = MessagingModuleConfiguration.shared.storage
val memberChange = message.inner.memberChangeMessage
val type = memberChange.type
val timestamp = message.sentTimestamp!!
verifyAdminSignature(closedGroup,
memberChange.adminSignature.toByteArray(),
buildMemberChangeSignature(type, timestamp)
)
storage.insertGroupInfoChange(message, closedGroup)
}
private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) {
val storage = MessagingModuleConfiguration.shared.storage
GlobalScope.launch(Dispatchers.Default) {
storage.handleMemberLeft(message, closedGroup)
}
}
private fun handleMemberLeftNotification(message: GroupUpdated, closedGroup: AccountId) {
MessagingModuleConfiguration.shared.storage.handleMemberLeftNotification(message, closedGroup)
}
private fun handleGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) {
val storage = MessagingModuleConfiguration.shared.storage
val inner = message.inner
val infoChanged = inner.infoChangeMessage ?: return
if (!infoChanged.hasAdminSignature()) return Log.e("GroupUpdated", "Info changed message doesn't contain admin signature")
val adminSignature = infoChanged.adminSignature
val type = infoChanged.type
val timestamp = message.sentTimestamp!!
verifyAdminSignature(closedGroup, adminSignature.toByteArray(), buildInfoChangeVerifier(type, timestamp))
storage.insertGroupInfoChange(message, closedGroup)
}
private fun handlePromotionMessage(message: GroupUpdated) {
val storage = MessagingModuleConfiguration.shared.storage
val promotion = message.inner.promoteMessage
val seed = promotion.groupIdentitySeed.toByteArray()
val keyPair = Sodium.ed25519KeyPair(seed)
val sender = message.sender!!
val adminId = AccountId(sender)
storage.addClosedGroupInvite(
groupId = AccountId(IdPrefix.GROUP, keyPair.pubKey),
name = promotion.name,
authData = null,
adminKey = keyPair.secretKey,
invitingAdmin = adminId,
message.serverHash
)
}
private fun MessageReceiver.handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) {
val sender = message.sender!!
// val profile = message // maybe we do need data to be the inner so we can access profile
val storage = MessagingModuleConfiguration.shared.storage
val approved = message.inner.inviteResponse.isApproved
storage.setGroupInviteCompleteIfNeeded(approved, sender, closedGroup)
}
private fun MessageReceiver.handleNewLibSessionClosedGroupMessage(message: GroupUpdated) {
val storage = MessagingModuleConfiguration.shared.storage
val ourUserId = storage.getUserPublicKey()!!
val invite = message.inner.inviteMessage
val groupId = AccountId(invite.groupSessionId)
verifyAdminSignature(
groupSessionId = groupId,
signatureData = invite.adminSignature.toByteArray(),
messageToValidate = buildGroupInviteSignature(AccountId(ourUserId), message.sentTimestamp!!)
)
val sender = message.sender!!
val adminId = AccountId(sender)
// add the group
storage.addClosedGroupInvite(
groupId,
invite.name,
invite.memberAuthData.toByteArray(),
null,
adminId,
message.serverHash
)
}
/**
* Does nothing on successful signature verification, throws otherwise.
* Assumes the signer is using the ed25519 group key signing key
* @param groupSessionId the AccountId of the group to check the signature against
* @param signatureData the byte array supplied to us through a protobuf message from the admin
* @param messageToValidate the expected values used for this signature generation, often something like `INVITE||{inviteeSessionId}||{timestamp}`
* @throws SignatureException if signature cannot be verified with given parameters
*/
private fun verifyAdminSignature(groupSessionId: AccountId, signatureData: ByteArray, messageToValidate: ByteArray) {
val groupPubKey = groupSessionId.pubKeyBytes
if (!SodiumUtilities.verifySignature(signatureData, groupPubKey, messageToValidate)) {
throw SignatureException("Verification failed for signature data")
}
}
private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) {
val storage = MessagingModuleConfiguration.shared.storage
val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return
@@ -595,7 +786,7 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli
storage.insertIncomingInfoMessage(context, sender, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, sentTimestamp)
}
// Start polling
ClosedGroupPollerV2.shared.startPolling(groupPublicKey)
LegacyClosedGroupPollerV2.shared.startPolling(groupPublicKey)
}
private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGroupControlMessage) {
@@ -807,7 +998,7 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup
}
// Notify the user
val type = if (senderLeft) SignalServiceGroup.Type.MEMBER_LEFT else SignalServiceGroup.Type.MEMBER_REMOVED
val type = if (senderLeft) SignalServiceGroup.Type.QUIT else SignalServiceGroup.Type.MEMBER_REMOVED
// We don't display zombie members in the notification as users have already been notified when those members left
val notificationMembers = removedMembers.minus(zombies)
if (notificationMembers.isNotEmpty()) {
@@ -871,7 +1062,7 @@ private fun MessageReceiver.handleClosedGroupMemberLeft(message: ClosedGroupCont
// Notify the user
if (!userLeft) {
storage.insertIncomingInfoMessage(context, senderPublicKey, groupID, SignalServiceGroup.Type.MEMBER_LEFT, name, listOf(senderPublicKey), admins, message.sentTimestamp!!)
storage.insertIncomingInfoMessage(context, senderPublicKey, groupID, SignalServiceGroup.Type.QUIT, name, listOf(senderPublicKey), admins, message.sentTimestamp!!)
}
}
@@ -901,12 +1092,13 @@ fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, grou
// Notify the PN server
PushRegistryV1.unsubscribeGroup(groupPublicKey, publicKey = userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
if (delete) {
val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
storage.cancelPendingMessageSendJobs(threadId)
storage.deleteConversation(threadId)
storage.getThreadId(Address.fromSerialized(groupID))?.let { threadId ->
storage.cancelPendingMessageSendJobs(threadId)
storage.deleteConversation(threadId)
}
}
}
// endregion

View File

@@ -76,7 +76,8 @@ public abstract class Attachment {
public boolean isInProgress() {
return transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_DONE &&
transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED;
transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED &&
transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING;
}
public long getSize() {

View File

@@ -55,9 +55,8 @@ public class DatabaseAttachment extends Attachment {
@Override
public boolean equals(Object other) {
return other != null &&
other instanceof DatabaseAttachment &&
((DatabaseAttachment) other).attachmentId.equals(this.attachmentId);
return other instanceof DatabaseAttachment &&
((DatabaseAttachment) other).attachmentId.equals(this.attachmentId);
}
@Override

View File

@@ -16,17 +16,13 @@ data class SubscriptionRequest(
/** the 33-byte account being subscribed to; typically an account ID */
val pubkey: String,
/** when the pubkey starts with 05 (i.e. an account ID) this is the ed25519 32-byte pubkey associated with the account ID */
val session_ed25519: String?,
/** 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth (new closed groups) */
val subkey_tag: String? = null,
val session_ed25519: String? = null,
/** array of integer namespaces to subscribe to, **must be sorted in ascending order** */
val namespaces: List<Int>,
/** if provided and true then notifications will include the body of the message (as long as it isn't too large) */
val data: Boolean,
/** the signature unix timestamp in seconds, not ms */
val sig_ts: Long,
/** the 64-byte ed25519 signature */
val signature: String,
/** the string identifying the notification service, "firebase" for android (currently) */
val service: String,
/** dict of service-specific data, currently just "token" field with device-specific token but different services might have other requirements */
@@ -41,13 +37,11 @@ data class UnsubscriptionRequest(
/** the 33-byte account being subscribed to; typically a account ID */
val pubkey: String,
/** when the pubkey starts with 05 (i.e. an account ID) this is the ed25519 32-byte pubkey associated with the account ID */
val session_ed25519: String?,
val session_ed25519: String? = null,
/** 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth (new closed groups) */
val subkey_tag: String? = null,
/** the signature unix timestamp in seconds, not ms */
val sig_ts: Long,
/** the 64-byte ed25519 signature */
val signature: String,
/** the string identifying the notification service, "firebase" for android (currently) */
val service: String,
/** dict of service-specific data, currently just "token" field with device-specific token but different services might have other requirements */

View File

@@ -1,54 +1,53 @@
package org.session.libsession.messaging.sending_receiving.notifications
import android.annotation.SuppressLint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.GlobalScope
import nl.komponents.kovenant.Promise
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.OnionResponse
import org.session.libsession.snode.Version
import org.session.libsession.snode.utilities.asyncPromise
import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.emptyPromise
import org.session.libsignal.utilities.retryIfNeeded
import org.session.libsignal.utilities.sideEffect
import org.session.libsignal.utilities.retryWithUniformInterval
@SuppressLint("StaticFieldLeak")
object PushRegistryV1 {
private val TAG = PushRegistryV1::class.java.name
val context = MessagingModuleConfiguration.shared.context
private const val maxRetryCount = 4
private const val MAX_RETRY_COUNT = 4
private val server = Server.LEGACY
@Suppress("OPT_IN_USAGE")
private val scope: CoroutineScope = GlobalScope
fun register(
device: Device,
isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context),
token: String? = TextSecurePreferences.getPushToken(context),
publicKey: String? = TextSecurePreferences.getLocalNumber(context),
legacyGroupPublicKeys: Collection<String> = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys()
): Promise<*, Exception> = when {
isPushEnabled -> retryIfNeeded(maxRetryCount) {
Log.d(TAG, "register() called")
doRegister(token, publicKey, device, legacyGroupPublicKeys)
} fail { exception ->
Log.d(TAG, "Couldn't register for FCM due to error", exception)
): Promise<*, Exception> = scope.asyncPromise {
if (isPushEnabled) {
retryWithUniformInterval(maxRetryCount = MAX_RETRY_COUNT) { doRegister(publicKey, device, legacyGroupPublicKeys) }
}
else -> emptyPromise()
}
private fun doRegister(token: String?, publicKey: String?, device: Device, legacyGroupPublicKeys: Collection<String>): Promise<*, Exception> {
private suspend fun doRegister(publicKey: String?, device: Device, legacyGroupPublicKeys: Collection<String>) {
Log.d(TAG, "doRegister() called")
token ?: return emptyPromise()
publicKey ?: return emptyPromise()
val token = MessagingModuleConfiguration.shared.tokenFetcher.fetch()
publicKey ?: return
val parameters = mapOf(
"token" to token,
@@ -58,52 +57,38 @@ object PushRegistryV1 {
)
val url = "${server.url}/register_legacy_groups_only"
val body = RequestBody.create(
"application/json".toMediaType(),
JsonUtil.toJson(parameters)
)
val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(url).post(body).build()
return sendOnionRequest(request) sideEffect { response ->
when (response.code) {
null, 0 -> throw Exception("error: ${response.message}.")
}
} success {
Log.d(TAG, "registerV1 success")
}
sendOnionRequest(request).await().checkError()
Log.d(TAG, "registerV1 success")
}
/**
* Unregister push notifications for 1-1 conversations as this is now done in FirebasePushManager.
*/
fun unregister(): Promise<*, Exception> {
fun unregister(): Promise<*, Exception> = scope.asyncPromise {
Log.d(TAG, "unregisterV1 requested")
val token = TextSecurePreferences.getPushToken(context) ?: emptyPromise()
return retryIfNeeded(maxRetryCount) {
retryWithUniformInterval(maxRetryCount = MAX_RETRY_COUNT) {
val token = MessagingModuleConfiguration.shared.tokenFetcher.fetch()
val parameters = mapOf("token" to token)
val url = "${server.url}/unregister"
val body = RequestBody.create("application/json".toMediaType(), JsonUtil.toJson(parameters))
val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(url).post(body).build()
sendOnionRequest(request) success {
when (it.code) {
null, 0 -> Log.d(TAG, "error: ${it.message}.")
else -> Log.d(TAG, "unregisterV1 success")
}
}
sendOnionRequest(request).await().checkError()
Log.d(TAG, "unregisterV1 success")
}
}
// Legacy Closed Groups
fun subscribeGroup(
closedGroupPublicKey: String,
closedGroupSessionId: String,
isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context),
publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!
) = if (isPushEnabled) {
performGroupOperation("subscribe_closed_group", closedGroupPublicKey, publicKey)
performGroupOperation("subscribe_closed_group", closedGroupSessionId, publicKey)
} else emptyPromise()
fun unsubscribeGroup(
@@ -118,18 +103,22 @@ object PushRegistryV1 {
operation: String,
closedGroupPublicKey: String,
publicKey: String
): Promise<*, Exception> {
): Promise<*, Exception> = scope.asyncPromise {
val parameters = mapOf("closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey)
val url = "${server.url}/$operation"
val body = RequestBody.create("application/json".toMediaType(), JsonUtil.toJson(parameters))
val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(url).post(body).build()
return retryIfNeeded(maxRetryCount) {
sendOnionRequest(request) sideEffect {
when (it.code) {
0, null -> throw Exception(it.message)
}
}
retryWithUniformInterval(MAX_RETRY_COUNT) {
sendOnionRequest(request)
.await()
.checkError()
}
}
private fun OnionResponse.checkError() {
check(code != null && code != 0) {
"error: $message."
}
}

View File

@@ -0,0 +1,376 @@
package org.session.libsession.messaging.sending_receiving.pollers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.libsession_util.GroupInfoConfig
import network.loki.messenger.libsession_util.GroupKeysConfig
import network.loki.messenger.libsession_util.GroupMembersConfig
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.Sodium
import org.session.libsession.database.StorageProtocol
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.Destination
import org.session.libsession.snode.GroupSubAccountSwarmAuth
import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.snode.RawResponse
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.model.BatchResponse
import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.withGroupConfigsOrNull
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.session.libsignal.utilities.Snode
import kotlin.time.Duration.Companion.days
class ClosedGroupPoller(
private val scope: CoroutineScope,
private val executor: CoroutineDispatcher,
private val closedGroupSessionId: AccountId,
private val configFactoryProtocol: ConfigFactoryProtocol,
private val storageProtocol: StorageProtocol = MessagingModuleConfiguration.shared.storage) {
data class ParsedRawMessage(
val data: ByteArray,
val hash: String,
val timestamp: Long
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParsedRawMessage
if (!data.contentEquals(other.data)) return false
if (hash != other.hash) return false
if (timestamp != other.timestamp) return false
return true
}
override fun hashCode(): Int {
var result = data.contentHashCode()
result = 31 * result + hash.hashCode()
result = 31 * result + timestamp.hashCode()
return result
}
}
companion object {
const val POLL_INTERVAL = 3_000L
const val ENABLE_LOGGING = false
}
private var job: Job? = null
fun start() {
if (job?.isActive == true) return // already started, don't restart
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "Starting closed group poller for ${closedGroupSessionId.hexString.take(4)}")
job?.cancel()
job = scope.launch(executor) {
val closedGroups = configFactoryProtocol.userGroups ?: return@launch
while (isActive) {
val group = closedGroups.getClosedGroup(closedGroupSessionId.hexString) ?: break
val nextPoll = runCatching { poll(group) }
when {
nextPoll.isFailure -> {
Log.e("ClosedGroupPoller", "Error polling closed group ${closedGroupSessionId.hexString}: ${nextPoll.exceptionOrNull()}")
delay(POLL_INTERVAL)
}
nextPoll.getOrNull() == null -> {
// assume null poll time means don't continue polling, either the group has been deleted or something else
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "Stopping the closed group poller")
break
}
else -> {
delay(nextPoll.getOrThrow()!!)
}
}
}
}
}
fun stop() {
job?.cancel()
job = null
}
private suspend fun poll(group: GroupInfo.ClosedGroupInfo): Long? {
val snode = SnodeAPI.getSingleTargetSnode(closedGroupSessionId.hexString).await()
configFactoryProtocol.withGroupConfigsOrNull(closedGroupSessionId) { info, members, keys ->
val hashesToExtend = mutableSetOf<String>()
hashesToExtend += info.currentHashes()
hashesToExtend += members.currentHashes()
hashesToExtend += keys.currentHashes()
val authData = group.authData
val adminKey = group.adminKey
val auth = if (authData != null) {
GroupSubAccountSwarmAuth(
groupKeysConfig = keys,
accountId = group.groupAccountId,
authData = authData
)
} else if (adminKey != null) {
OwnedSwarmAuth.ofClosedGroup(
groupAccountId = group.groupAccountId,
adminKey = adminKey
)
} else {
Log.e("ClosedGroupPoller", "No auth data for group, polling is cancelled")
return null
}
val revokedPoll = SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
snode = snode,
auth = auth,
namespace = Namespace.REVOKED_GROUP_MESSAGES(),
maxSize = null,
)
val messagePoll = SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
snode = snode,
auth = auth,
namespace = Namespace.CLOSED_GROUP_MESSAGES(),
maxSize = null,
)
val infoPoll = SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
snode = snode,
auth = auth,
namespace = info.namespace(),
maxSize = null,
)
val membersPoll = SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
snode = snode,
auth = auth,
namespace = members.namespace(),
maxSize = null,
)
val keysPoll = SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
snode = snode,
auth = auth,
namespace = keys.namespace(),
maxSize = null,
)
val requests = mutableListOf(keysPoll, revokedPoll, infoPoll, membersPoll, messagePoll)
if (hashesToExtend.isNotEmpty()) {
requests += SnodeAPI.buildAuthenticatedAlterTtlBatchRequest(
messageHashes = hashesToExtend.toList(),
auth = auth,
newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds,
extend = true
)
}
val pollResult = SnodeAPI.getRawBatchResponse(
snode = snode,
publicKey = closedGroupSessionId.hexString,
requests = requests
).await()
// If we no longer have a group, stop poller
if (configFactoryProtocol.userGroups?.getClosedGroup(closedGroupSessionId.hexString) == null) return null
// if poll result body is null here we don't have any things ig
if (ENABLE_LOGGING) Log.d(
"ClosedGroupPoller",
"Poll results @${SnodeAPI.nowWithOffset}:"
)
requests.asSequence()
.zip((pollResult["results"] as List<RawResponse>).asSequence())
.forEach { (request, response) ->
when (request) {
revokedPoll -> handleRevoked(response, keys)
keysPoll -> handleKeyPoll(response, keys, info, members)
infoPoll -> handleInfo(response, info)
membersPoll -> handleMembers(response, members)
messagePoll -> handleMessages(response, snode, keys)
else -> {}
}
}
val requiresSync =
info.needsPush() || members.needsPush() || keys.needsRekey() || keys.pendingConfig() != null
if (info.needsDump() || members.needsDump() || keys.needsDump()) {
configFactoryProtocol.saveGroupConfigs(keys, info, members)
}
if (requiresSync) {
configFactoryProtocol.scheduleUpdate(Destination.ClosedGroup(closedGroupSessionId.hexString))
}
}
return POLL_INTERVAL // this might change in future
}
private fun parseMessages(response: RawResponse): List<ParsedRawMessage> {
val body = response["body"] as? RawResponse
if (body == null) {
if (ENABLE_LOGGING) Log.e("GroupPoller", "Batch parse messages contained no body!")
return emptyList()
}
val messages = body["messages"] as? List<*> ?: return emptyList()
return messages.mapNotNull { messageMap ->
val rawMessageAsJSON = messageMap as? Map<*, *> ?: return@mapNotNull null
val base64EncodedData = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null
val hash = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null
val timestamp = rawMessageAsJSON["timestamp"] as? Long ?: return@mapNotNull null
val data = base64EncodedData.let { Base64.decode(it) }
ParsedRawMessage(data, hash, timestamp)
}
}
private fun handleRevoked(response: RawResponse, keys: GroupKeysConfig) {
// This shouldn't ever return null at this point
val userSessionId = configFactoryProtocol.userSessionId()!!
val body = response["body"] as? RawResponse
if (body == null) {
if (ENABLE_LOGGING) Log.e("GroupPoller", "No revoked messages")
return
}
val messages = body["messages"] as? List<*>
?: return Log.w("GroupPoller", "body didn't contain a list of messages")
messages.forEach { messageMap ->
val rawMessageAsJSON = messageMap as? Map<*,*>
?: return@forEach Log.w("GroupPoller", "rawMessage wasn't a map as expected")
val data = rawMessageAsJSON["data"] as? String ?: return@forEach
val hash = rawMessageAsJSON["hash"] as? String ?: return@forEach
val timestamp = rawMessageAsJSON["timestamp"] as? Long ?: return@forEach
Log.d("GroupPoller", "Handling message with hash $hash")
val decoded = configFactoryProtocol.maybeDecryptForUser(
Base64.decode(data),
Sodium.KICKED_DOMAIN,
closedGroupSessionId,
)
if (decoded != null) {
Log.d("GroupPoller", "decoded kick message was for us")
val message = decoded.decodeToString()
if (Sodium.KICKED_REGEX.matches(message)) {
val (sessionId, generation) = message.split("-")
if (sessionId == userSessionId.hexString && generation.toInt() >= keys.currentGeneration()) {
Log.d("GroupPoller", "We were kicked from the group, delete and stop polling")
stop()
configFactoryProtocol.userGroups?.let { userGroups ->
userGroups.getClosedGroup(closedGroupSessionId.hexString)?.let { group ->
// Retrieve the group name one last time from the group info,
// as we are going to clear the keys, we won't have the chance to
// read the group name anymore.
val groupName = configFactoryProtocol.getGroupInfoConfig(closedGroupSessionId)
?.use { it.getName() }
?: group.name
userGroups.set(group.copy(
authData = null,
adminKey = null,
name = groupName
))
configFactoryProtocol.persist(userGroups, SnodeAPI.nowWithOffset)
}
}
storageProtocol.handleKicked(closedGroupSessionId)
MessagingModuleConfiguration.shared.storage.insertIncomingInfoMessage(
context = MessagingModuleConfiguration.shared.context,
senderPublicKey = userSessionId.hexString,
groupID = closedGroupSessionId.hexString,
type = SignalServiceGroup.Type.KICKED,
name = "",
members = emptyList(),
admins = emptyList(),
sentTimestamp = SnodeAPI.nowWithOffset,
)
}
}
}
}
}
private fun handleKeyPoll(response: RawResponse,
keysConfig: GroupKeysConfig,
infoConfig: GroupInfoConfig,
membersConfig: GroupMembersConfig) {
// get all the data to hash objects and process them
val allMessages = parseMessages(response)
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "Total key messages this poll: ${allMessages.size}")
var total = 0
allMessages.forEach { (message, hash, timestamp) ->
if (keysConfig.loadKey(message, hash, timestamp, infoConfig, membersConfig)) {
total++
}
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "Merged $hash for keys on ${closedGroupSessionId.hexString}")
}
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "Total key messages consumed: $total")
}
private fun handleInfo(response: RawResponse,
infoConfig: GroupInfoConfig) {
val messages = parseMessages(response)
messages.forEach { (message, hash, _) ->
infoConfig.merge(hash to message)
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "Merged $hash for info on ${closedGroupSessionId.hexString}")
}
if (messages.isNotEmpty()) {
val lastTimestamp = messages.maxOf { it.timestamp }
MessagingModuleConfiguration.shared.storage.notifyConfigUpdates(infoConfig, lastTimestamp)
}
}
private fun handleMembers(response: RawResponse,
membersConfig: GroupMembersConfig) {
parseMessages(response).forEach { (message, hash, _) ->
membersConfig.merge(hash to message)
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "Merged $hash for members on ${closedGroupSessionId.hexString}")
}
}
private fun handleMessages(response: RawResponse, snode: Snode, keysConfig: GroupKeysConfig) {
val body = response["body"] as RawResponse
val messages = SnodeAPI.parseRawMessagesResponse(
rawResponse = body,
snode = snode,
publicKey = closedGroupSessionId.hexString,
decrypt = keysConfig::decrypt
)
val parameters = messages.map { (envelope, serverHash) ->
MessageReceiveParameters(
envelope.toByteArray(),
serverHash = serverHash,
closedGroup = Destination.ClosedGroup(closedGroupSessionId.hexString)
)
}
parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk ->
val job = BatchMessageReceiveJob(chunk)
JobQueue.shared.add(job)
}
if (ENABLE_LOGGING) Log.d("ClosedGroupPoller", "namespace for messages rx count: ${messages.size}")
}
}

View File

@@ -22,7 +22,7 @@ import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import kotlin.math.min
class ClosedGroupPollerV2 {
class LegacyClosedGroupPollerV2 {
private val executorService = Executors.newScheduledThreadPool(1)
private var isPolling = mutableMapOf<String, Boolean>()
private var futures = mutableMapOf<String, ScheduledFuture<*>>()
@@ -36,7 +36,7 @@ class ClosedGroupPollerV2 {
private val maxPollInterval = 4 * 60 * 1000
@JvmStatic
val shared = ClosedGroupPollerV2()
val shared = LegacyClosedGroupPollerV2()
}
class InsufficientSnodesException() : Exception("No snodes left to poll.")
@@ -108,13 +108,13 @@ class ClosedGroupPollerV2 {
if (!isPolling(groupPublicKey)) { throw PollingCanceledException() }
val currentForkInfo = SnodeAPI.forkInfo
when {
currentForkInfo.defaultRequiresAuth() -> SnodeAPI.getRawMessages(snode, groupPublicKey, requiresAuth = false, namespace = Namespace.UNAUTHENTICATED_CLOSED_GROUP)
.map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey, Namespace.UNAUTHENTICATED_CLOSED_GROUP) }
currentForkInfo.defaultRequiresAuth() -> SnodeAPI.getUnauthenticatedRawMessages(snode, groupPublicKey, namespace = Namespace.UNAUTHENTICATED_CLOSED_GROUP())
.map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey, Namespace.UNAUTHENTICATED_CLOSED_GROUP()) }
currentForkInfo.hasNamespaces() -> task {
val unAuthed = SnodeAPI.getRawMessages(snode, groupPublicKey, requiresAuth = false, namespace = Namespace.UNAUTHENTICATED_CLOSED_GROUP)
.map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey, Namespace.UNAUTHENTICATED_CLOSED_GROUP) }
val default = SnodeAPI.getRawMessages(snode, groupPublicKey, requiresAuth = false, namespace = Namespace.DEFAULT)
.map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey, Namespace.DEFAULT) }
val unAuthed = SnodeAPI.getUnauthenticatedRawMessages(snode, groupPublicKey, namespace = Namespace.UNAUTHENTICATED_CLOSED_GROUP())
.map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey, Namespace.UNAUTHENTICATED_CLOSED_GROUP()) }
val default = SnodeAPI.getUnauthenticatedRawMessages(snode, groupPublicKey, namespace = Namespace.DEFAULT())
.map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey, Namespace.DEFAULT()) }
val unAuthedResult = unAuthed.get()
val defaultResult = default.get()
val format = DateFormat.getTimeInstance()
@@ -123,7 +123,7 @@ class ClosedGroupPollerV2 {
}
unAuthedResult + defaultResult
}
else -> SnodeAPI.getRawMessages(snode, groupPublicKey, requiresAuth = false, namespace = Namespace.DEFAULT)
else -> SnodeAPI.getUnauthenticatedRawMessages(snode, groupPublicKey, namespace = Namespace.DEFAULT())
.map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey) }
}
}

View File

@@ -281,7 +281,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S
mappingCache[it.recipient] = mapping
}
val threadId = Message.getThreadId(message, null, MessagingModuleConfiguration.shared.storage, false)
MessageReceiver.handle(message, proto, threadId ?: -1, null)
MessageReceiver.handle(message, proto, threadId ?: -1, null, null)
} catch (e: Exception) {
Log.e("Loki", "Couldn't handle direct message", e)
}

View File

@@ -15,6 +15,7 @@ import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.resolve
import nl.komponents.kovenant.task
import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.JobQueue
@@ -36,7 +37,7 @@ private const val TAG = "Poller"
private class PromiseCanceledException : Exception("Promise canceled.")
class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Timer) {
class Poller(private val configFactory: ConfigFactoryProtocol) {
var userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() ?: ""
private var hasStarted: Boolean = false
private val usedSnodes: MutableSet<Snode> = mutableSetOf()
@@ -178,12 +179,15 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
runBlocking(Dispatchers.IO) {
val requests = mutableListOf<SnodeAPI.SnodeBatchRequestInfo>()
val hashesToExtend = mutableSetOf<String>()
val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth)
configFactory.user?.let { config ->
hashesToExtend += config.currentHashes()
SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
snode, userPublicKey,
config.configNamespace(),
maxSize = -8
snode = snode,
auth = userAuth,
namespace = config.namespace(),
maxSize = -8
)
}?.let { request ->
requests += request
@@ -192,7 +196,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
if (hashesToExtend.isNotEmpty()) {
SnodeAPI.buildAuthenticatedAlterTtlBatchRequest(
messageHashes = hashesToExtend.toList(),
publicKey = userPublicKey,
auth = userAuth,
newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds,
extend = true
)?.let { extensionRequest ->
@@ -213,7 +217,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
if (body == null) {
Log.e(TAG, "Batch sub-request didn't contain a body")
} else {
processConfig(snode, body, configFactory.user!!.configNamespace(), configFactory.user)
processConfig(snode, body, configFactory.user!!.namespace(), configFactory.user)
}
}
}
@@ -230,19 +234,22 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
if (!hasStarted) { return Promise.ofFail(PromiseCanceledException()) }
return task {
runBlocking(Dispatchers.IO) {
val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth)
val requestSparseArray = SparseArray<SnodeAPI.SnodeBatchRequestInfo>()
// get messages
SnodeAPI.buildAuthenticatedRetrieveBatchRequest(snode, userPublicKey, maxSize = -2)!!.also { personalMessages ->
SnodeAPI.buildAuthenticatedRetrieveBatchRequest(snode, auth = userAuth, maxSize = -2)
.also { personalMessages ->
// namespaces here should always be set
requestSparseArray[personalMessages.namespace!!] = personalMessages
}
// get the latest convo info volatile
val hashesToExtend = mutableSetOf<String>()
configFactory.getUserConfigs().mapNotNull { config ->
configFactory.getUserConfigs().map { config ->
hashesToExtend += config.currentHashes()
SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
snode, userPublicKey,
config.configNamespace(),
snode = snode,
auth = userAuth,
namespace = config.namespace(),
maxSize = -8
)
}.forEach { request ->
@@ -256,7 +263,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
if (hashesToExtend.isNotEmpty()) {
SnodeAPI.buildAuthenticatedAlterTtlBatchRequest(
messageHashes = hashesToExtend.toList(),
publicKey = userPublicKey,
auth = userAuth,
newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds,
extend = true
)?.let { extensionRequest ->
@@ -274,10 +281,10 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
// 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
listOfNotNull(
configFactory.user?.configNamespace(),
configFactory.contacts?.configNamespace(),
configFactory.userGroups?.configNamespace(),
configFactory.convoVolatile?.configNamespace()
configFactory.user?.namespace(),
configFactory.contacts?.namespace(),
configFactory.userGroups?.namespace(),
configFactory.convoVolatile?.namespace()
).map {
it to requestSparseArray.indexOfKey(it)
}.filter { (_, i) -> i >= 0 }.forEach { (key, requestIndex) ->
@@ -291,7 +298,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
Log.e(TAG, "Batch sub-request didn't contain a body")
return@forEach
}
if (key == Namespace.DEFAULT) {
if (key == Namespace.DEFAULT()) {
return@forEach // continue, skip default namespace
} else {
when (ConfigBase.kindFor(key)) {
@@ -305,7 +312,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
}
// the first response will be the personal messages (we want these to be processed after config messages)
val personalResponseIndex = requestSparseArray.indexOfKey(Namespace.DEFAULT)
val personalResponseIndex = requestSparseArray.indexOfKey(Namespace.DEFAULT())
if (personalResponseIndex >= 0) {
responseList.getOrNull(personalResponseIndex)?.let { rawResponse ->
if (rawResponse["code"] as? Int != 200) {

View File

@@ -0,0 +1,39 @@
package org.session.libsession.messaging.utilities
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.AccountId
object MessageAuthentication {
fun buildInfoChangeVerifier(
changeType: SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage.Type,
timestamp: Long): ByteArray {
return "INFO_CHANGE${changeType.number}$timestamp".toByteArray()
}
fun buildDeleteMemberContentSignature(
memberIds: Iterable<AccountId>,
messageHashes: Iterable<String>,
timestamp: Long
): ByteArray {
return buildString {
append("DELETE_CONTENT")
append(timestamp)
memberIds.forEach { append(it.hexString) }
messageHashes.forEach(this::append)
}.toByteArray()
}
fun buildMemberChangeSignature(
changeType: SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage.Type,
timestamp: Long
): ByteArray {
return "MEMBER_CHANGE${changeType.number}$timestamp".toByteArray()
}
fun buildGroupInviteSignature(
memberId: AccountId,
timestamp: Long
): ByteArray {
return "INVITE${memberId.hexString}$timestamp".toByteArray()
}
}

View File

@@ -1,10 +1,10 @@
package org.session.libsession.messaging.utilities
import com.google.protobuf.ByteString
import org.session.libsignal.utilities.Log
import org.session.libsignal.protos.SignalServiceProtos.Envelope
import org.session.libsignal.protos.WebSocketProtos.WebSocketMessage
import org.session.libsignal.protos.WebSocketProtos.WebSocketRequestMessage
import org.session.libsignal.utilities.Log
import java.security.SecureRandom
object MessageWrapper {
@@ -32,7 +32,7 @@ object MessageWrapper {
}
}
private fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope {
fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope {
try {
val builder = Envelope.newBuilder()
builder.type = type
@@ -59,7 +59,7 @@ object MessageWrapper {
type = WebSocketMessage.Type.REQUEST
}.build()
} catch (e: Exception) {
Log.d("Loki", "Failed to wrap envelope in web socket message: ${e.message}.")
Log.d("MessageWrapper", "Failed to wrap envelope in web socket message: ${e.message}.")
throw Error.FailedToWrapEnvelopeInWebSocketMessage
}
}
@@ -73,9 +73,9 @@ object MessageWrapper {
try {
val webSocketMessage = WebSocketMessage.parseFrom(data)
val envelopeAsData = webSocketMessage.request.body
return Envelope.parseFrom(envelopeAsData);
return Envelope.parseFrom(envelopeAsData)
} catch (e: Exception) {
Log.d("Loki", "Failed to unwrap data: ${e.message}.")
Log.d("MessageWrapper", "Failed to unwrap data", e)
throw Error.FailedToUnwrapData
}
}

View File

@@ -5,11 +5,12 @@ import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.AEAD
import com.goterl.lazysodium.interfaces.GenericHash
import com.goterl.lazysodium.interfaces.Hash
import com.goterl.lazysodium.interfaces.Sign
import com.goterl.lazysodium.utils.Key
import com.goterl.lazysodium.utils.KeyPair
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.toHexString
import org.whispersystems.curve25519.Curve25519
import kotlin.experimental.xor
@@ -29,7 +30,13 @@ object SodiumUtilities {
val serverPubKeyData = Hex.fromStringCondensed(serverPublicKey)
if (serverPubKeyData.size != PUBLIC_KEY_LENGTH) return null
val serverPubKeyHash = ByteArray(GenericHash.BLAKE2B_BYTES_MAX)
if (!sodium.cryptoGenericHash(serverPubKeyHash, serverPubKeyHash.size, serverPubKeyData, serverPubKeyData.size.toLong())) {
if (!sodium.cryptoGenericHash(
serverPubKeyHash,
serverPubKeyHash.size,
serverPubKeyData,
serverPubKeyData.size.toLong()
)
) {
return null
}
// Reduce the server public key into an ed25519 scalar (`k`)
@@ -37,7 +44,7 @@ object SodiumUtilities {
sodium.cryptoCoreEd25519ScalarReduce(x25519PublicKey, serverPubKeyHash)
return if (x25519PublicKey.any { it.toInt() != 0 }) {
x25519PublicKey
} else null
} else null
}
/*
@@ -56,7 +63,7 @@ object SodiumUtilities {
/* Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` */
@JvmStatic
fun blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair): KeyPair? {
fun blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair): KeyPair? {
if (edKeyPair.publicKey.asBytes.size != PUBLIC_KEY_LENGTH || edKeyPair.secretKey.asBytes.size != SECRET_KEY_LENGTH) return null
val kBytes = generateBlindingFactor(serverPublicKey) ?: return null
val aBytes = generatePrivateKeyScalar(edKeyPair.secretKey.asBytes) ?: return null
@@ -111,7 +118,7 @@ object SodiumUtilities {
val sig_s = ByteArray(SCALAR_LENGTH)
sodium.cryptoCoreEd25519ScalarMul(sig_sMul, hRam, blindedSecretKey)
if (sig_sMul.any { it.toInt() != 0 }) {
sodium.cryptoCoreEd25519ScalarAdd(sig_s, r, sig_sMul)
sodium.cryptoCoreEd25519ScalarAdd(sig_s, r, sig_sMul)
if (sig_s.all { it.toInt() == 0 }) return null
} else return null
@@ -154,7 +161,13 @@ object SodiumUtilities {
val combinedKeyBytes = combineKeys(aBytes, otherBlindedPublicKey) ?: return null
val outputHash = ByteArray(GenericHash.KEYBYTES)
val inputBytes = combinedKeyBytes + kA + kB
return if (sodium.cryptoGenericHash(outputHash, outputHash.size, inputBytes, inputBytes.size.toLong())) {
return if (sodium.cryptoGenericHash(
outputHash,
outputHash.size,
inputBytes,
inputBytes.size.toLong()
)
) {
outputHash
} else null
}
@@ -165,6 +178,10 @@ object SodiumUtilities {
blindedAccountId: String,
serverPublicKey: String
): Boolean {
if (standardAccountId.isBlank() || blindedAccountId.isBlank() || serverPublicKey.isBlank()) {
return false
}
// Only support generating blinded keys for standard account ids
val accountId = AccountId(standardAccountId)
if (accountId.prefix != IdPrefix.STANDARD) return false
@@ -174,7 +191,8 @@ object SodiumUtilities {
// From the account id (ignoring 05 prefix) we have two possible ed25519 pubkeys;
// the first is the positive (which is what Signal's XEd25519 conversion always uses)
val xEd25519Key = curve.convertToEd25519PublicKey(Key.fromHexString(accountId.publicKey).asBytes)
val xEd25519Key =
curve.convertToEd25519PublicKey(accountId.pubKeyBytes)
// Blind the positive public key
val pk1 = combineKeys(k, xEd25519Key) ?: return false
@@ -182,11 +200,16 @@ object SodiumUtilities {
// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2
// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000])
val pk2 = pk1.take(31).toByteArray() + listOf(pk1.last().xor(128.toByte())).toByteArray()
return AccountId(IdPrefix.BLINDED, pk1).publicKey == blindedId.publicKey ||
AccountId(IdPrefix.BLINDED, pk2).publicKey == blindedId.publicKey
return AccountId(IdPrefix.BLINDED, pk1).hexString == blindedId.hexString ||
AccountId(IdPrefix.BLINDED, pk2).hexString == blindedId.hexString
}
fun encrypt(message: ByteArray, secretKey: ByteArray, nonce: ByteArray, additionalData: ByteArray? = null): ByteArray? {
fun encrypt(
message: ByteArray,
secretKey: ByteArray,
nonce: ByteArray,
additionalData: ByteArray? = null
): ByteArray? {
val authenticatedCipherText = ByteArray(message.size + AEAD.CHACHA20POLY1305_ABYTES)
return if (sodium.cryptoAeadXChaCha20Poly1305IetfEncrypt(
authenticatedCipherText,
@@ -230,23 +253,29 @@ object SodiumUtilities {
} else null
}
}
class AccountId {
var prefix: IdPrefix?
var publicKey: String
constructor(id: String) {
prefix = IdPrefix.fromValue(id)
publicKey = id.drop(2)
/**
* Returns true only if the signature verified successfully
*/
fun verifySignature(
signature: ByteArray,
publicKey: ByteArray,
messageToVerify: ByteArray
): Boolean {
return sodium.cryptoSignVerifyDetached(
signature,
messageToVerify, messageToVerify.size, publicKey)
}
constructor(prefix: IdPrefix, publicKey: ByteArray) {
this.prefix = prefix
this.publicKey = publicKey.toHexString()
}
/**
* For signing
*/
fun sign(message: ByteArray, signingKey: ByteArray): ByteArray {
val signature = ByteArray(Sign.BYTES)
val hexString
get() = prefix?.value + publicKey
if (!sodium.cryptoSignDetached(signature, message, message.size.toLong(), signingKey)) {
throw SecurityException("Couldn't sign the message with the signing key")
}
return signature
}
}

View File

@@ -12,10 +12,12 @@ import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED
import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.utilities.Address
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.SCREENSHOT
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.getExpirationTypeDisplayValue
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.Log
import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY
@@ -27,7 +29,8 @@ import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY
import org.session.libsession.utilities.Util
object UpdateMessageBuilder {
const val TAG = "libsession"
const val TAG = "UpdateMessageBuilder"
val storage = MessagingModuleConfiguration.shared.storage
@@ -35,9 +38,12 @@ object UpdateMessageBuilder {
?.displayName(Contact.ContactContext.REGULAR)
?: truncateIdForDisplay(senderId)
fun buildGroupUpdateMessage(context: Context, updateMessageData: UpdateMessageData, senderId: String? = null, isOutgoing: Boolean = false): CharSequence {
val updateData = updateMessageData.kind
if (updateData == null || !isOutgoing && senderId == null) return ""
@JvmStatic
fun buildGroupUpdateMessage(context: Context, updateMessageData: UpdateMessageData, senderId: String? = null, isOutgoing: Boolean = false, isInConversation: Boolean): CharSequence {
val updateData = updateMessageData.kind ?: return ""
val senderName: String by lazy {
senderId?.let(this::getSenderName).orEmpty()
}
return when (updateData) {
// --- Group created or joined ---
@@ -148,8 +154,6 @@ object UpdateMessageBuilder {
}
}
}
// --- Group members left ---
is UpdateMessageData.Kind.GroupMemberLeft -> {
if (isOutgoing) context.getText(R.string.groupMemberYouLeft)
else {
@@ -172,10 +176,137 @@ object UpdateMessageBuilder {
}
}
}
else -> return ""
is UpdateMessageData.Kind.GroupAvatarUpdated -> context.getString(R.string.groupDisplayPictureUpdated)
is UpdateMessageData.Kind.GroupExpirationUpdated -> TODO()
is UpdateMessageData.Kind.GroupMemberUpdated -> {
val userPublicKey = storage.getUserPublicKey()!!
val number = updateData.sessionIds.size
val containsUser = updateData.sessionIds.contains(userPublicKey)
when (updateData.type) {
UpdateMessageData.MemberUpdateType.ADDED -> {
when {
number == 1 && containsUser -> Phrase.from(context,
R.string.groupInviteYou)
.format()
number == 1 -> Phrase.from(context,
R.string.groupMemberNew)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.format()
number == 2 && containsUser -> Phrase.from(context,
R.string.groupMemberYouAndOtherNew)
.put(OTHER_NAME_KEY, context.youOrSender(updateData.sessionIds.first { it != userPublicKey }))
.format()
number == 2 -> Phrase.from(context,
R.string.groupMemberTwoNew)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.put(OTHER_NAME_KEY, context.youOrSender(updateData.sessionIds.last()))
.format()
containsUser -> Phrase.from(context,
R.string.groupMemberNewYouMultiple)
.put(COUNT_KEY, updateData.sessionIds.size - 1)
.format()
else -> Phrase.from(context,
R.string.groupMemberMoreNew)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.put(COUNT_KEY, updateData.sessionIds.size - 1)
.format()
}
}
UpdateMessageData.MemberUpdateType.PROMOTED -> {
when {
number == 1 && containsUser -> context.getString(
R.string.groupPromotedYou
)
number == 1 -> Phrase.from(context,
R.string.adminPromotedToAdmin)
.put(NAME_KEY,context.youOrSender(updateData.sessionIds.first()))
.format()
number == 2 && containsUser -> Phrase.from(context,
R.string.groupPromotedYouTwo)
.put(OTHER_NAME_KEY, context.youOrSender(updateData.sessionIds.first{ it != userPublicKey }))
.format()
number == 2 -> Phrase.from(context,
R.string.adminTwoPromotedToAdmin)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.put(OTHER_NAME_KEY, context.youOrSender(updateData.sessionIds.last()))
.format()
containsUser -> Phrase.from(context,
R.string.groupPromotedYouMultiple)
.put(COUNT_KEY, updateData.sessionIds.size - 1)
.format()
else -> Phrase.from(context,
R.string.adminMorePromotedToAdmin)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.put(COUNT_KEY, updateData.sessionIds.size - 1)
.format()
}
}
UpdateMessageData.MemberUpdateType.REMOVED -> {
when {
number == 1 && containsUser -> Phrase.from(context,
R.string.groupRemovedYou)
.put(GROUP_NAME_KEY, updateData.groupName)
.format()
number == 1 -> Phrase.from(context,
R.string.groupRemoved)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.format()
number == 2 && containsUser -> Phrase.from(context,
R.string.groupRemovedYouTwo)
.put(OTHER_NAME_KEY, context.youOrSender(updateData.sessionIds.first { it != userPublicKey }))
.format()
number == 2 -> Phrase.from(context,
R.string.groupRemovedTwo)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.put(OTHER_NAME_KEY, context.youOrSender(updateData.sessionIds.last()))
.format()
containsUser -> Phrase.from(context,
R.string.groupRemovedYouMultiple)
.put(COUNT_KEY, updateData.sessionIds.size - 1)
.format()
else -> Phrase.from(context,
R.string.groupRemovedMultiple)
.put(NAME_KEY, context.youOrSender(updateData.sessionIds.first()))
.put(COUNT_KEY, updateData.sessionIds.size - 1)
.format()
}
}
null -> ""
}
}
is UpdateMessageData.Kind.GroupInvitation -> {
val invitingAdmin = Recipient.from(context, Address.fromSerialized(updateData.invitingAdmin), false)
return if (invitingAdmin.name != null) {
Phrase.from(context, R.string.messageRequestGroupInvite)
.put(NAME_KEY, invitingAdmin.name)
.put(GROUP_NAME_KEY, updateData.groupName)
.format()
} else {
context.getString(R.string.groupInviteYou)
}
}
is UpdateMessageData.Kind.OpenGroupInvitation -> ""
is UpdateMessageData.Kind.GroupLeaving -> {
return if (isOutgoing) {
context.getString(R.string.leaving)
} else {
""
}
}
is UpdateMessageData.Kind.GroupErrorQuit -> {
return context.getString(R.string.groupLeaveErrorFailed)
}
is UpdateMessageData.Kind.GroupKicked -> {
return Phrase.from(context, R.string.groupRemovedYou)
.put(GROUP_NAME_KEY, updateData.groupName)
.format()
}
}
}
fun Context.youOrSender(sessionId: String) = if (storage.getUserPublicKey() == sessionId) getString(R.string.you) else getSenderName(sessionId)
fun buildExpirationTimerMessage(
context: Context,
duration: Long,
@@ -253,12 +384,19 @@ object UpdateMessageBuilder {
}
fun buildCallMessage(context: Context, type: CallMessageType, senderId: String): String {
val senderName = storage.getContactWithAccountID(senderId)?.displayName(Contact.ContactContext.REGULAR) ?: senderId
val senderName =
storage.getContactWithAccountID(senderId)?.displayName(Contact.ContactContext.REGULAR)
?: senderId
return when (type) {
CALL_INCOMING -> Phrase.from(context, R.string.callsCalledYou).put(NAME_KEY, senderName).format().toString()
CALL_OUTGOING -> Phrase.from(context, R.string.callsYouCalled).put(NAME_KEY, senderName).format().toString()
CALL_MISSED, CALL_FIRST_MISSED -> Phrase.from(context, R.string.callsMissedCallFrom).put(NAME_KEY, senderName).format().toString()
CALL_INCOMING -> Phrase.from(context, R.string.callsCalledYou).put(NAME_KEY, senderName)
.format().toString()
CALL_OUTGOING -> Phrase.from(context, R.string.callsYouCalled).put(NAME_KEY, senderName)
.format().toString()
CALL_MISSED, CALL_FIRST_MISSED -> Phrase.from(context, R.string.callsMissedCallFrom)
.put(NAME_KEY, senderName).format().toString()
}
}
}

View File

@@ -1,12 +1,16 @@
package org.session.libsession.messaging.utilities
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.core.JsonParseException
import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage.Type
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
import java.util.*
import java.util.Collections
// class used to save update messages details
class UpdateMessageData () {
@@ -16,15 +20,22 @@ class UpdateMessageData () {
//the annotations below are required for serialization. Any new Kind class MUST be declared as JsonSubTypes as well
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonSubTypes(
JsonSubTypes.Type(Kind.GroupCreation::class, name = "GroupCreation"),
JsonSubTypes.Type(Kind.GroupNameChange::class, name = "GroupNameChange"),
JsonSubTypes.Type(Kind.GroupMemberAdded::class, name = "GroupMemberAdded"),
JsonSubTypes.Type(Kind.GroupMemberRemoved::class, name = "GroupMemberRemoved"),
JsonSubTypes.Type(Kind.GroupMemberLeft::class, name = "GroupMemberLeft"),
JsonSubTypes.Type(Kind.OpenGroupInvitation::class, name = "OpenGroupInvitation")
JsonSubTypes.Type(Kind.GroupCreation::class, name = "GroupCreation"),
JsonSubTypes.Type(Kind.GroupNameChange::class, name = "GroupNameChange"),
JsonSubTypes.Type(Kind.GroupMemberAdded::class, name = "GroupMemberAdded"),
JsonSubTypes.Type(Kind.GroupMemberRemoved::class, name = "GroupMemberRemoved"),
JsonSubTypes.Type(Kind.GroupMemberLeft::class, name = "GroupMemberLeft"),
JsonSubTypes.Type(Kind.OpenGroupInvitation::class, name = "OpenGroupInvitation"),
JsonSubTypes.Type(Kind.GroupAvatarUpdated::class, name = "GroupAvatarUpdated"),
JsonSubTypes.Type(Kind.GroupMemberUpdated::class, name = "GroupMemberUpdated"),
JsonSubTypes.Type(Kind.GroupExpirationUpdated::class, name = "GroupExpirationUpdated"),
JsonSubTypes.Type(Kind.GroupInvitation::class, name = "GroupInvitation"),
JsonSubTypes.Type(Kind.GroupLeaving::class, name = "GroupLeaving"),
JsonSubTypes.Type(Kind.GroupErrorQuit::class, name = "GroupErrorQuit"),
JsonSubTypes.Type(Kind.GroupKicked::class, name = "GroupKicked")
)
sealed class Kind() {
class GroupCreation(): Kind()
sealed class Kind {
data object GroupCreation: Kind()
class GroupNameChange(val name: String): Kind() {
constructor(): this("") //default constructor required for json serialization
}
@@ -37,9 +48,37 @@ class UpdateMessageData () {
class GroupMemberLeft(val updatedMembers: Collection<String>, val groupName:String): Kind() {
constructor(): this(Collections.emptyList(), "")
}
class GroupMemberUpdated(val sessionIds: List<String>, val type: MemberUpdateType?, val groupName: String): Kind() {
constructor(): this(emptyList(), null, "")
}
data object GroupAvatarUpdated: Kind()
class GroupExpirationUpdated(val updatedExpiration: Int = 0): Kind() {
constructor(): this(0)
}
class OpenGroupInvitation(val groupUrl: String, val groupName: String): Kind() {
constructor(): this("", "")
}
data object GroupLeaving: Kind()
data object GroupErrorQuit: Kind()
class GroupInvitation(val invitingAdmin: String, val groupName: String) : Kind() {
constructor(): this("", "")
}
class GroupKicked(val groupName: String) : Kind() {
constructor(): this("")
}
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonSubTypes(
JsonSubTypes.Type(MemberUpdateType.ADDED::class, name = "ADDED"),
JsonSubTypes.Type(MemberUpdateType.REMOVED::class, name = "REMOVED"),
JsonSubTypes.Type(MemberUpdateType.PROMOTED::class, name = "PROMOTED"),
)
sealed class MemberUpdateType {
data object ADDED: MemberUpdateType()
data object REMOVED: MemberUpdateType()
data object PROMOTED: MemberUpdateType()
}
constructor(kind: Kind): this() {
@@ -51,11 +90,49 @@ class UpdateMessageData () {
fun buildGroupUpdate(type: SignalServiceGroup.Type, name: String, members: Collection<String>): UpdateMessageData? {
return when(type) {
SignalServiceGroup.Type.CREATION -> UpdateMessageData(Kind.GroupCreation())
SignalServiceGroup.Type.NAME_CHANGE -> UpdateMessageData(Kind.GroupNameChange(name))
SignalServiceGroup.Type.MEMBER_ADDED -> UpdateMessageData(Kind.GroupMemberAdded(members, name))
SignalServiceGroup.Type.CREATION -> UpdateMessageData(Kind.GroupCreation)
SignalServiceGroup.Type.NAME_CHANGE -> UpdateMessageData(Kind.GroupNameChange(name))
SignalServiceGroup.Type.MEMBER_ADDED -> UpdateMessageData(Kind.GroupMemberAdded(members, name))
SignalServiceGroup.Type.MEMBER_REMOVED -> UpdateMessageData(Kind.GroupMemberRemoved(members, name))
SignalServiceGroup.Type.MEMBER_LEFT -> UpdateMessageData(Kind.GroupMemberLeft(members, name))
SignalServiceGroup.Type.QUIT -> UpdateMessageData(Kind.GroupMemberLeft(members, name))
SignalServiceGroup.Type.LEAVING -> UpdateMessageData(Kind.GroupLeaving)
SignalServiceGroup.Type.ERROR_QUIT -> UpdateMessageData(Kind.GroupErrorQuit)
SignalServiceGroup.Type.KICKED -> UpdateMessageData(Kind.GroupKicked(name))
SignalServiceGroup.Type.UNKNOWN,
SignalServiceGroup.Type.UPDATE,
SignalServiceGroup.Type.DELIVER,
SignalServiceGroup.Type.REQUEST_INFO -> null
}
}
fun buildGroupUpdate(groupUpdated: GroupUpdated, groupName: String): UpdateMessageData? {
val inner = groupUpdated.inner
return when {
inner.hasMemberChangeMessage() -> {
val memberChange = inner.memberChangeMessage
val type = when (memberChange.type) {
Type.ADDED -> MemberUpdateType.ADDED
Type.PROMOTED -> MemberUpdateType.PROMOTED
Type.REMOVED -> MemberUpdateType.REMOVED
null -> null
}
val members = memberChange.memberSessionIdsList
UpdateMessageData(Kind.GroupMemberUpdated(members, type, groupName))
}
inner.hasInfoChangeMessage() -> {
val infoChange = inner.infoChangeMessage
val type = infoChange.type
when (type) {
GroupUpdateInfoChangeMessage.Type.NAME -> Kind.GroupNameChange(infoChange.updatedName)
GroupUpdateInfoChangeMessage.Type.AVATAR -> Kind.GroupAvatarUpdated
GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES -> Kind.GroupExpirationUpdated(infoChange.updatedExpiration)
else -> null
}?.let { UpdateMessageData(it) }
}
inner.hasMemberLeftNotificationMessage() -> UpdateMessageData(Kind.GroupMemberLeft(
updatedMembers = listOf(groupUpdated.sender.orEmpty()),
groupName = groupName
))
else -> null
}
}
@@ -64,6 +141,11 @@ class UpdateMessageData () {
return UpdateMessageData(Kind.OpenGroupInvitation(url, name))
}
fun buildGroupLeaveUpdate(newType: Kind): UpdateMessageData {
return UpdateMessageData(newType)
}
@JvmStatic
fun fromJSON(json: String): UpdateMessageData? {
return try {
JsonUtil.fromJson(json, UpdateMessageData::class.java)
@@ -72,9 +154,18 @@ class UpdateMessageData () {
null
}
}
}
fun toJSON(): String {
return JsonUtil.toJson(this)
}
fun isGroupLeavingKind(): Boolean {
return kind is Kind.GroupLeaving
}
fun isGroupErrorQuitKind(): Boolean {
return kind is Kind.GroupErrorQuit
}
}

View File

@@ -0,0 +1,39 @@
package org.session.libsession.snode
import network.loki.messenger.libsession_util.GroupKeysConfig
import org.session.libsignal.utilities.AccountId
/**
* A [SwarmAuth] that signs message using a group's subaccount. This should be used for non-admin
* users of a group signing their messages.
*/
class GroupSubAccountSwarmAuth(
private val groupKeysConfig: GroupKeysConfig,
override val accountId: AccountId,
private val authData: ByteArray
) : SwarmAuth {
override val ed25519PublicKeyHex: String? get() = null
init {
check(authData.size == 100) {
"Invalid auth data size, expecting 100 but got ${authData.size}"
}
}
override fun sign(data: ByteArray): Map<String, String> {
val auth = groupKeysConfig.subAccountSign(data, authData)
return buildMap {
put("subaccount", auth.subAccount)
put("subaccount_sig", auth.subAccountSig)
put("signature", auth.signature)
}
}
override fun signForPushRegistry(data: ByteArray): Map<String, String> {
val auth = groupKeysConfig.subAccountSign(data, authData)
return buildMap {
put("subkey_tag", auth.subAccount)
put("signature", auth.signature)
}
}
}

View File

@@ -0,0 +1,49 @@
package org.session.libsession.snode
import com.goterl.lazysodium.interfaces.Sign
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
/**
* A [SwarmAuth] that signs message using a single ED25519 private key.
*
* This should be used for the owner of an account, like a user or a group admin.
*/
class OwnedSwarmAuth(
override val accountId: AccountId,
override val ed25519PublicKeyHex: String?,
val ed25519PrivateKey: ByteArray,
) : SwarmAuth {
init {
check(ed25519PrivateKey.size == Sign.SECRETKEYBYTES) {
"Invalid secret key size, expecting ${Sign.SECRETKEYBYTES} but got ${ed25519PrivateKey.size}"
}
}
override fun sign(data: ByteArray): Map<String, String> {
val signature = Base64.encodeBytes(ByteArray(Sign.BYTES).also {
check(sodium.cryptoSignDetached(it, data, data.size.toLong(), ed25519PrivateKey)) {
"Failed to sign data"
}
})
return buildMap {
put("signature", signature)
}
}
override fun signForPushRegistry(data: ByteArray): Map<String, String> {
return sign(data)
}
companion object {
fun ofClosedGroup(groupAccountId: AccountId, adminKey: ByteArray): OwnedSwarmAuth {
return OwnedSwarmAuth(
accountId = groupAccountId,
ed25519PublicKeyHex = null,
ed25519PrivateKey = adminKey
)
}
}
}

View File

@@ -6,32 +6,35 @@ import com.goterl.lazysodium.exceptions.SodiumException
import com.goterl.lazysodium.interfaces.GenericHash
import com.goterl.lazysodium.interfaces.PwHash
import com.goterl.lazysodium.interfaces.SecretBox
import com.goterl.lazysodium.interfaces.Sign
import com.goterl.lazysodium.utils.Key
import com.goterl.lazysodium.utils.KeyPair
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.withContext
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.task
import nl.komponents.kovenant.unwrap
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsession.utilities.buildMutableMap
import org.session.libsession.snode.model.BatchResponse
import org.session.libsession.snode.utilities.await
import org.session.libsession.snode.utilities.retrySuspendAsPromise
import org.session.libsession.utilities.mapValuesNotNull
import org.session.libsession.utilities.toByteArray
import org.session.libsignal.crypto.secureRandom
import org.session.libsignal.crypto.shuffledRandom
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Broadcaster
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.prettifiedDescription
import org.session.libsignal.utilities.retryIfNeeded
@@ -92,12 +95,15 @@ object SnodeAPI {
private const val KEY_ED25519 = "pubkey_ed25519"
private const val KEY_VERSION = "storage_server_version"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Error
sealed class Error(val description: String) : Exception(description) {
object Generic : Error("An error occurred.")
object ClockOutOfSync : Error("Your clock is out of sync with the Service Node network.")
object NoKeyPair : Error("Missing user key pair.")
object SigningFailed : Error("Couldn't sign verification data.")
// ONS
object DecryptionFailed : Error("Couldn't decrypt ONS name.")
object HashingFailed : Error("Couldn't compute ONS name hash.")
@@ -123,6 +129,7 @@ object SnodeAPI {
useOnionRequests -> OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).map {
JsonUtil.fromJson(it.body ?: throw Error.Generic, Map::class.java)
}
else -> task {
HTTP.execute(
HTTP.Verb.POST,
@@ -142,6 +149,33 @@ object SnodeAPI {
}
}
private suspend fun<Res> invokeSuspend(
method: Snode.Method,
snode: Snode,
parameters: Map<String, Any>,
responseClass: Class<Res>,
publicKey: String? = null,
version: Version = Version.V3
): Res = when {
useOnionRequests -> {
val resp = OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).await()
JsonUtil.fromJson(resp.body ?: throw Error.Generic, responseClass)
}
else -> withContext(Dispatchers.IO) {
HTTP.execute(
HTTP.Verb.POST,
url = "${snode.address}:${snode.port}/storage_rpc/v1",
parameters = buildMap {
this["method"] = method.rawValue
this["params"] = parameters
}
).toString().let {
JsonUtil.fromJson(it, responseClass)
}
}
}
private val GET_RANDOM_SNODE_PARAMS = buildMap<String, Any> {
this["method"] = "get_n_service_nodes"
this["params"] = buildMap {
@@ -194,7 +228,7 @@ object SnodeAPI {
}
}
internal fun getSingleTargetSnode(publicKey: String): Promise<Snode, Exception> {
fun getSingleTargetSnode(publicKey: String): Promise<Snode, Exception> {
// SecureRandom should be cryptographically secure
return getSwarm(publicKey).map { it.shuffledRandom().random() }
}
@@ -274,76 +308,112 @@ object SnodeAPI {
database.setSwarm(publicKey, it)
}
private fun signAndEncodeCatching(data: ByteArray, userED25519KeyPair: KeyPair): Result<String> =
runCatching { signAndEncode(data, userED25519KeyPair) }
private fun signAndEncode(data: ByteArray, userED25519KeyPair: KeyPair): String =
sign(data, userED25519KeyPair).let(Base64::encodeBytes)
private fun sign(data: ByteArray, userED25519KeyPair: KeyPair): ByteArray = ByteArray(Sign.BYTES).also {
sodium.cryptoSignDetached(it, data, data.size.toLong(), userED25519KeyPair.secretKey.asBytes)
/**
* Build parameters required to call authenticated storage API.
*
* @param auth The authentication data required to sign the request
* @param namespace The namespace of the messages you want to retrieve. Null if not relevant.
* @param verificationData A function that returns the data to be signed. The function takes the namespace text and timestamp as arguments.
* @param timestamp The timestamp to be used in the request. Default is the current time.
* @param builder A lambda that allows the user to add additional parameters to the request.
*/
private fun buildAuthenticatedParameters(
auth: SwarmAuth,
namespace: Int?,
verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null,
timestamp: Long = nowWithOffset,
builder: MutableMap<String, Any>.() -> Unit = {}
): Map<String, Any> {
return buildMap {
// Build user provided parameter first
this.builder()
if (verificationData != null) {
// Namespace shouldn't be in the verification data if it's null or 0.
val namespaceText = when (namespace) {
null, 0 -> ""
else -> namespace.toString()
}
val verifyData = when (val verify = verificationData(namespaceText, timestamp)) {
is String -> verify.toByteArray()
is ByteArray -> verify
else -> throw IllegalArgumentException("verificationData must return a String or ByteArray")
}
putAll(auth.sign(verifyData))
put("timestamp", timestamp)
}
put("pubkey", auth.accountId.hexString)
if (namespace != null && namespace != 0) {
put("namespace", namespace)
}
auth.ed25519PublicKeyHex?.let { put("pubkey_ed25519", it) }
}
}
private fun getUserED25519KeyPairCatchingOrNull() = runCatching { MessagingModuleConfiguration.shared.getUserED25519KeyPair() }.getOrNull()
private fun getUserED25519KeyPair(): KeyPair? = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
private fun getUserPublicKey() = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
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 = buildMutableMap<String, Any> {
this["pubKey"] = publicKey
this["last_hash"] = lastHashValue
// If the namespace is default (0) here it will be implicitly read as 0 on the storage server
// we only need to specify it explicitly if we want to (in future) or if it is non-zero
namespace.takeIf { it != 0 }?.let { this["namespace"] = it }
}
// Construct signature
if (requiresAuth) {
val userED25519KeyPair = try {
getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair)
} catch (e: Exception) {
Log.e("Loki", "Error getting KeyPair", e)
return Promise.ofFail(Error.NoKeyPair)
}
val timestamp = System.currentTimeMillis() + clockOffset
val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString
val verificationData = buildString {
append("retrieve")
namespace.takeIf { it != 0 }?.let(::append)
append(timestamp)
}.toByteArray()
parameters["signature"] = signAndEncodeCatching(verificationData, userED25519KeyPair).getOrNull()
?: return Promise.ofFail(Error.SigningFailed)
parameters["timestamp"] = timestamp
parameters["pubkey_ed25519"] = ed25519PublicKey
/**
* Retrieve messages from the swarm.
*
* @param snode The swarm service where you want to retrieve messages from. It can be a swarm for a specific user or a group. Call [getSingleTargetSnode] to get a swarm node.
* @param auth The authentication data required to retrieve messages. This can be a user or group authentication data.
* @param namespace The namespace of the messages you want to retrieve. Default is 0.
*/
fun getRawMessages(
snode: Snode,
auth: SwarmAuth,
namespace: Int = 0
): RawResponsePromise {
val parameters = buildAuthenticatedParameters(
namespace = namespace,
auth = auth,
verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" }
) {
put(
"last_hash",
database.getLastMessageHashValue(snode, auth.accountId.hexString, namespace).orEmpty()
)
}
// Make the request
return invoke(Snode.Method.Retrieve, snode, parameters, auth.accountId.hexString)
}
fun getUnauthenticatedRawMessages(
snode: Snode,
publicKey: String,
namespace: Int = 0
): RawResponsePromise {
val parameters = buildMap {
put("last_hash", database.getLastMessageHashValue(snode, publicKey, namespace).orEmpty())
put("pubkey", publicKey)
if (namespace != 0) {
put("namespace", namespace)
}
}
return invoke(Snode.Method.Retrieve, snode, parameters, publicKey)
}
fun buildAuthenticatedStoreBatchInfo(namespace: Int, message: SnodeMessage): SnodeBatchRequestInfo? {
// used for sig generation since it is also the value used in timestamp parameter
val messageTimestamp = message.timestamp
val userED25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null
val verificationData = "store$namespace$messageTimestamp".toByteArray()
val signature = signAndEncodeCatching(verificationData, userED25519KeyPair).run {
getOrNull() ?: return null.also { Log.e("Loki", "Signing data failed with user secret key", exceptionOrNull()) }
fun buildAuthenticatedStoreBatchInfo(
namespace: Int,
message: SnodeMessage,
auth: SwarmAuth,
): SnodeBatchRequestInfo {
check(message.recipient == auth.accountId.hexString) {
"Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}"
}
val params = buildMap {
// load the message data params into the sub request
// currently loads:
// pubKey
// data
// ttl
// timestamp
val params = buildAuthenticatedParameters(
namespace = namespace,
auth = auth,
verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" },
timestamp = message.timestamp
) {
putAll(message.toJSON())
this["namespace"] = namespace
// timestamp already set
this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString
this["signature"] = signature
}
return SnodeBatchRequestInfo(
@@ -353,29 +423,78 @@ object SnodeAPI {
)
}
fun buildAuthenticatedUnrevokeSubKeyBatchRequest(
groupAdminAuth: OwnedSwarmAuth,
subAccountTokens: List<ByteArray>,
): SnodeBatchRequestInfo {
val params = buildAuthenticatedParameters(
namespace = null,
auth = groupAdminAuth,
verificationData = { _, t ->
subAccountTokens.fold(
"${Snode.Method.UnrevokeSubAccount.rawValue}$t".toByteArray()
) { acc, subAccount -> acc + subAccount }
}
) {
put("unrevoke", subAccountTokens.map(Base64::encodeBytes))
}
return SnodeBatchRequestInfo(
Snode.Method.UnrevokeSubAccount.rawValue,
params,
null
)
}
fun buildAuthenticatedRevokeSubKeyBatchRequest(
groupAdminAuth: OwnedSwarmAuth,
subAccountTokens: List<ByteArray>,
): SnodeBatchRequestInfo {
val params = buildAuthenticatedParameters(
namespace = null,
auth = groupAdminAuth,
verificationData = { _, t ->
subAccountTokens.fold(
"${Snode.Method.RevokeSubAccount.rawValue}$t".toByteArray()
) { acc, subAccount -> acc + subAccount }
}
) {
put("revoke", subAccountTokens.map(Base64::encodeBytes))
}
return SnodeBatchRequestInfo(
Snode.Method.RevokeSubAccount.rawValue,
params,
null
)
}
/**
* 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 ed25519PubKey the destination's ed25519 public key to delete from. Only required for user messages.
* @param messageHashes a list of stored message hashes to delete from all namespaces on 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<String>, required: Boolean = false): SnodeBatchRequestInfo? {
val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null
val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString
val verificationData = sequenceOf("delete").plus(messageHashes).toByteArray()
val signature = try {
signAndEncode(verificationData, userEd25519KeyPair)
} catch (e: Exception) {
Log.e("Loki", "Signing data failed with user secret key", e)
return null
}
val params = buildMap {
this["pubkey"] = publicKey
this["required"] = required // could be omitted technically but explicit here
this["messages"] = messageHashes
this["pubkey_ed25519"] = ed25519PublicKey
this["signature"] = signature
fun buildAuthenticatedDeleteBatchInfo(
auth: SwarmAuth,
messageHashes: List<String>,
required: Boolean = false
): SnodeBatchRequestInfo {
val params = buildAuthenticatedParameters(
namespace = null,
auth = auth,
verificationData = { _, _ ->
buildString {
append(Snode.Method.DeleteMessage.rawValue)
messageHashes.forEach(this::append)
}
}
) {
put("messages", messageHashes)
put("required", required)
}
return SnodeBatchRequestInfo(
Snode.Method.DeleteMessage.rawValue,
params,
@@ -383,28 +502,23 @@ object SnodeAPI {
)
}
fun buildAuthenticatedRetrieveBatchRequest(snode: Snode, publicKey: String, namespace: Int = 0, maxSize: Int? = null): SnodeBatchRequestInfo? {
val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: ""
val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null
val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString
val timestamp = System.currentTimeMillis() + clockOffset
val verificationData = if (namespace == 0) "retrieve$timestamp".toByteArray()
else "retrieve$namespace$timestamp".toByteArray()
val signature = try {
signAndEncode(verificationData, userEd25519KeyPair)
} catch (e: Exception) {
Log.e("Loki", "Signing data failed with user secret key", e)
return null
}
val params = buildMap {
this["pubkey"] = publicKey
this["last_hash"] = lastHashValue
this["timestamp"] = timestamp
this["pubkey_ed25519"] = ed25519PublicKey
this["signature"] = signature
if (namespace != 0) this["namespace"] = namespace
if (maxSize != null) this["max_size"] = maxSize
fun buildAuthenticatedRetrieveBatchRequest(
snode: Snode,
auth: SwarmAuth,
namespace: Int = 0,
maxSize: Int? = null
): SnodeBatchRequestInfo {
val params = buildAuthenticatedParameters(
namespace = namespace,
auth = auth,
verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" },
) {
put("last_hash", database.getLastMessageHashValue(snode, auth.accountId.hexString, namespace).orEmpty())
if (maxSize != null) {
put("max_size", maxSize)
}
}
return SnodeBatchRequestInfo(
Snode.Method.Retrieve.rawValue,
params,
@@ -413,12 +527,14 @@ object SnodeAPI {
}
fun buildAuthenticatedAlterTtlBatchRequest(
auth: SwarmAuth,
messageHashes: List<String>,
newExpiry: Long,
publicKey: String,
shorten: Boolean = false,
extend: Boolean = false): SnodeBatchRequestInfo? {
val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) ?: return null
extend: Boolean = false
): SnodeBatchRequestInfo {
val params =
buildAlterTtlParams(auth, messageHashes, newExpiry, extend, shorten)
return SnodeBatchRequestInfo(
Snode.Method.Expire.rawValue,
params,
@@ -426,9 +542,20 @@ object SnodeAPI {
)
}
fun getRawBatchResponse(snode: Snode, publicKey: String, requests: List<SnodeBatchRequestInfo>, sequence: Boolean = false): RawResponsePromise {
@Suppress("UNCHECKED_CAST")
fun getRawBatchResponse(
snode: Snode,
publicKey: String,
requests: List<SnodeBatchRequestInfo>,
sequence: Boolean = false
): RawResponsePromise {
val parameters = buildMap { this["requests"] = requests }
return invoke(if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode, parameters, publicKey).success { rawResponses ->
return invoke(
if (sequence) Snode.Method.Sequence else Snode.Method.Batch,
snode,
parameters,
publicKey
).success { rawResponses ->
rawResponses["results"].let { it as List<RawResponse> }
.asSequence()
.filter { it["code"] as? Int != 200 }
@@ -444,81 +571,90 @@ object SnodeAPI {
}
}
fun getExpiries(messageHashes: List<String>, publicKey: String) : RawResponsePromise {
val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return Promise.ofFail(NullPointerException("No user key pair"))
val hashes = messageHashes.takeIf { it.size != 1 } ?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes.
return retryIfNeeded(maxRetryCount) {
val timestamp = System.currentTimeMillis() + clockOffset
val signData = sequenceOf(Snode.Method.GetExpiries.rawValue).plus(timestamp.toString()).plus(hashes).toByteArray()
suspend fun getBatchResponse(
snode: Snode,
publicKey: String,
requests: List<SnodeBatchRequestInfo>,
sequence: Boolean = false
): BatchResponse {
return invokeSuspend(
method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch,
snode = snode,
parameters = mapOf("requests" to requests),
responseClass = BatchResponse::class.java,
publicKey = publicKey
)
}
val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString
val signature = try {
signAndEncode(signData, userEd25519KeyPair)
} catch (e: Exception) {
Log.e("Loki", "Signing data failed with user secret key", e)
return@retryIfNeeded Promise.ofFail(e)
}
val params = buildMap {
this["pubkey"] = publicKey
fun getExpiries(
messageHashes: List<String>,
auth: SwarmAuth,
): RawResponsePromise {
val hashes = messageHashes.takeIf { it.size != 1 }
?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes.
return scope.retrySuspendAsPromise(maxRetryCount) {
val params = buildAuthenticatedParameters(
auth = auth,
namespace = null,
verificationData = { _, t -> buildString {
append(Snode.Method.GetExpiries.rawValue)
append(t)
hashes.forEach(this::append)
} },
) {
this["messages"] = hashes
this["timestamp"] = timestamp
this["pubkey_ed25519"] = ed25519PublicKey
this["signature"] = signature
}
getSingleTargetSnode(publicKey) bind { snode ->
invoke(Snode.Method.GetExpiries, snode, params, publicKey)
}
val snode = getSingleTargetSnode(auth.accountId.hexString).await()
invoke(Snode.Method.GetExpiries, snode, params, auth.accountId.hexString).await()
}
}
fun alterTtl(messageHashes: List<String>, newExpiry: Long, publicKey: String, extend: Boolean = false, shorten: Boolean = false): RawResponsePromise =
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
fun alterTtl(
auth: SwarmAuth,
messageHashes: List<String>,
newExpiry: Long,
publicKey: String,
extend: Boolean = false,
shorten: Boolean = false
): Map<String, Any>? {
val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null
): RawResponsePromise = scope.retrySuspendAsPromise(maxRetryCount) {
val params = buildAlterTtlParams(auth, messageHashes, newExpiry, extend, shorten)
val snode = getSingleTargetSnode(auth.accountId.hexString).await()
invoke(Snode.Method.Expire, snode, params, auth.accountId.hexString).await()
}
private fun buildAlterTtlParams(
auth: SwarmAuth,
messageHashes: List<String>,
newExpiry: Long,
extend: Boolean = false,
shorten: Boolean = false
): Map<String, Any> {
val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else ""
val signData = sequenceOf(Snode.Method.Expire.rawValue).plus(shortenOrExtend).plus(newExpiry.toString()).plus(messageHashes).toByteArray()
val signature = try {
signAndEncode(signData, userEd25519KeyPair)
} catch (e: Exception) {
Log.e("Loki", "Signing data failed with user secret key", e)
return null
}
return buildMap {
return buildAuthenticatedParameters(
namespace = null,
auth = auth,
verificationData = { _, _ ->
buildString {
append("expire")
append(shortenOrExtend)
messageHashes.forEach(this::append)
}
}
) {
this["expiry"] = newExpiry
this["messages"] = messageHashes
when {
extend -> this["extend"] = true
shorten -> this["shorten"] = true
}
this["pubkey"] = publicKey
this["pubkey_ed25519"] = userEd25519KeyPair.publicKey.asHexString
this["signature"] = signature
}
}
fun getMessages(publicKey: String): MessageListPromise = retryIfNeeded(maxRetryCount) {
getSingleTargetSnode(publicKey).bind { snode ->
getRawMessages(snode, publicKey).map { parseRawMessagesResponse(it, snode, publicKey) }
}
fun getMessages(auth: SwarmAuth): MessageListPromise = scope.retrySuspendAsPromise(maxRetryCount) {
val snode = getSingleTargetSnode(auth.accountId.hexString).await()
val resp = getRawMessages(snode, auth).await()
parseRawMessagesResponse(resp, snode, auth.accountId.hexString)
}
private fun getNetworkTime(snode: Snode): Promise<Pair<Snode, Long>, Exception> =
@@ -527,81 +663,101 @@ object SnodeAPI {
snode to timestamp
}
fun sendMessage(message: SnodeMessage, requiresAuth: Boolean = false, namespace: Int = 0): RawResponsePromise =
retryIfNeeded(maxRetryCount) {
val userED25519KeyPair = getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair)
val parameters = message.toJSON().toMutableMap<String, Any>()
// Construct signature
if (requiresAuth) {
val sigTimestamp = nowWithOffset
val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString
// assume namespace here is non-zero, as zero namespace doesn't require auth
val verificationData = "store$namespace$sigTimestamp".toByteArray()
val signature = try {
signAndEncode(verificationData, userED25519KeyPair)
} catch (exception: Exception) {
return@retryIfNeeded Promise.ofFail(Error.SigningFailed)
/**
* Note: After this method returns, [auth] will not be used by any of async calls and it's afe
* for the caller to clean up the associated resources if needed.
*/
fun sendMessage(
message: SnodeMessage,
auth: SwarmAuth?,
namespace: Int = 0
): RawResponsePromise {
val params = if (auth != null) {
check(auth.accountId.hexString == message.recipient) {
"Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}"
}
buildAuthenticatedParameters(
auth = auth,
namespace = namespace,
verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" },
timestamp = message.timestamp
) {
put("sig_timestamp", message.timestamp)
putAll(message.toJSON())
}
} else {
buildMap {
putAll(message.toJSON())
if (namespace != 0) {
put("namespace", namespace)
}
parameters["sig_timestamp"] = sigTimestamp
parameters["pubkey_ed25519"] = ed25519PublicKey
parameters["signature"] = signature
}
// If the namespace is default (0) here it will be implicitly read as 0 on the storage server
// we only need to specify it explicitly if we want to (in future) or if it is non-zero
if (namespace != 0) {
parameters["namespace"] = namespace
}
}
return scope.retrySuspendAsPromise(maxRetryCount) {
val destination = message.recipient
getSingleTargetSnode(destination).bind { snode ->
invoke(Snode.Method.SendMessage, snode, parameters, destination)
val snode = getSingleTargetSnode(destination).await()
invoke(Snode.Method.SendMessage, snode, params, destination).await()
}
}
@Suppress("UNCHECKED_CAST")
fun deleteMessage(
publicKey: String,
swarmAuth: SwarmAuth,
serverHashes: List<String>
): Promise<Map<String, Boolean>, Exception> = scope.retrySuspendAsPromise(maxRetryCount) {
val params = buildAuthenticatedParameters(
auth = swarmAuth,
namespace = null,
verificationData = { _, _ ->
buildString {
append(Snode.Method.DeleteMessage.rawValue)
serverHashes.forEach(this::append)
}
}
) {
this["messages"] = serverHashes
}
fun deleteMessage(publicKey: String, serverHashes: List<String>): Promise<Map<String, Boolean>, Exception> =
retryIfNeeded(maxRetryCount) {
val userED25519KeyPair = getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair)
val userPublicKey = getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair)
getSingleTargetSnode(publicKey).bind { snode ->
retryIfNeeded(maxRetryCount) {
val verificationData = sequenceOf(Snode.Method.DeleteMessage.rawValue).plus(serverHashes).toByteArray()
val deleteMessageParams = buildMap {
this["pubkey"] = userPublicKey
this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString
this["messages"] = serverHashes
this["signature"] = signAndEncode(verificationData, userED25519KeyPair)
}
invoke(Snode.Method.DeleteMessage, snode, deleteMessageParams, publicKey).map { rawResponse ->
val swarms = rawResponse["swarm"] as? Map<String, Any> ?: return@map mapOf()
swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) ->
(rawJSON as? Map<String, Any>)?.let { json ->
val isFailed = json["failed"] as? Boolean ?: false
val statusCode = json["code"] as? String
val reason = json["reason"] as? String
val snode = getSingleTargetSnode(publicKey).await()
val rawResponse = invoke(Snode.Method.DeleteMessage, snode, params, publicKey).await()
val swarms = rawResponse["swarm"] as? Map<String, Any> ?: return@retrySuspendAsPromise mapOf()
swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) ->
(rawJSON as? Map<String, Any>)?.let { json ->
val isFailed = json["failed"] as? Boolean ?: false
val statusCode = json["code"] as? String
val reason = json["reason"] as? String
if (isFailed) {
Log.e("Loki", "Failed to delete messages from: $hexSnodePublicKey due to error: $reason ($statusCode).")
false
} else {
// Hashes of deleted messages
val hashes = json["deleted"] as List<String>
val signature = json["signature"] as String
val snodePublicKey = Key.fromHexString(hexSnodePublicKey)
// The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] )
val message = sequenceOf(userPublicKey).plus(serverHashes).plus(hashes).toByteArray()
sodium.cryptoSignVerifyDetached(
Base64.decode(signature),
message,
message.size,
snodePublicKey.asBytes
)
}
}
}
}.fail { e -> Log.e("Loki", "Failed to delete messages", e) }
if (isFailed) {
Log.e(
"Loki",
"Failed to delete messages from: $hexSnodePublicKey due to error: $reason ($statusCode)."
)
false
} else {
// Hashes of deleted messages
val hashes = json["deleted"] as List<String>
val signature = json["signature"] as String
val snodePublicKey = Key.fromHexString(hexSnodePublicKey)
// The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] )
val message = sequenceOf(swarmAuth.accountId.hexString)
.plus(serverHashes)
.plus(hashes)
.toByteArray()
sodium.cryptoSignVerifyDetached(
Base64.decode(signature),
message,
message.size,
snodePublicKey.asBytes
)
}
}
}
}
// Parsing
private fun parseSnodes(rawResponse: Any): List<Snode> =
(rawResponse as? Map<*, *>)
@@ -614,36 +770,45 @@ object SnodeAPI {
port = (it["port"] as? String)?.toInt(),
ed25519Key = it[KEY_ED25519] as? String,
x25519Key = it[KEY_X25519] as? String
).apply { if (this == null) Log.d("Loki", "Failed to parse snode from: ${it.prettifiedDescription()}.") }
}?.toList() ?: listOf<Snode>().also { Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.") }
fun deleteAllMessages(): Promise<Map<String,Boolean>, Exception> =
retryIfNeeded(maxRetryCount) {
val userED25519KeyPair = getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair)
val userPublicKey = getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair)
getSingleTargetSnode(userPublicKey).bind { snode ->
retryIfNeeded(maxRetryCount) {
getNetworkTime(snode).bind { (_, timestamp) ->
val verificationData = sequenceOf(Snode.Method.DeleteAll.rawValue, Namespace.ALL, timestamp.toString()).toByteArray()
val deleteMessageParams = buildMap {
this["pubkey"] = userPublicKey
this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString
this["timestamp"] = timestamp
this["signature"] = signAndEncode(verificationData, userED25519KeyPair)
this["namespace"] = Namespace.ALL
}
invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey)
.map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) }
.fail { e -> Log.e("Loki", "Failed to clear data", e) }
}
).apply {
if (this == null) Log.d(
"Loki",
"Failed to parse snode from: ${it.prettifiedDescription()}."
)
}
}
}?.toList() ?: listOf<Snode>().also {
Log.d(
"Loki",
"Failed to parse snodes from: ${rawResponse.prettifiedDescription()}."
)
}
fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true): List<Pair<SignalServiceProtos.Envelope, String?>> =
fun deleteAllMessages(auth: SwarmAuth): Promise<Map<String, Boolean>, Exception> =
scope.retrySuspendAsPromise(maxRetryCount) {
val snode = getSingleTargetSnode(auth.accountId.hexString).await()
val (_, timestamp) = getNetworkTime(snode).await()
val params = buildAuthenticatedParameters(
auth = auth,
namespace = null,
verificationData = { _, t -> "${Snode.Method.DeleteAll.rawValue}all$t" },
timestamp = timestamp
) {
put("namespace", "all")
}
val rawResponse = invoke(Snode.Method.DeleteAll, snode, params, auth.accountId.hexString).await()
parseDeletions(
auth.accountId.hexString,
timestamp,
rawResponse
)
}
fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true, decrypt: ((ByteArray) -> Pair<ByteArray, AccountId>?)? = null): List<Pair<SignalServiceProtos.Envelope, String?>> =
(rawResponse["messages"] as? List<*>)?.let { messages ->
if (updateLatestHash) updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace)
removeDuplicates(publicKey, messages, namespace, updateStoredHashes).let(::parseEnvelopes)
parseEnvelopes(removeDuplicates(publicKey, messages, namespace, updateStoredHashes), decrypt)
} ?: listOf()
fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) {
@@ -675,15 +840,29 @@ object SnodeAPI {
}
}
private fun parseEnvelopes(rawMessages: List<Map<*, *>>): List<Pair<SignalServiceProtos.Envelope, String?>> = rawMessages.mapNotNull { rawMessage ->
val base64EncodedData = rawMessage["data"] as? String
val data = base64EncodedData?.let(Base64::decode)
data ?: Log.d("Loki", "Failed to decode data for message: ${rawMessage.prettifiedDescription()}.")
data?.runCatching { MessageWrapper.unwrap(this) to rawMessage["hash"] as? String }
?.onFailure { Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.") }
?.getOrNull()
private fun parseEnvelopes(rawMessages: List<*>, decrypt: ((ByteArray)->Pair<ByteArray, AccountId>?)?): List<Pair<SignalServiceProtos.Envelope, String?>> {
return rawMessages.mapNotNull { rawMessage ->
val rawMessageAsJSON = rawMessage as? Map<*, *>
val base64EncodedData = rawMessageAsJSON?.get("data") as? String
val data = base64EncodedData?.let { Base64.decode(it) }
if (data != null) {
try {
if (decrypt != null) {
val (decrypted, sender) = decrypt(data)!!
val envelope = SignalServiceProtos.Envelope.parseFrom(decrypted).toBuilder()
envelope.source = sender.hexString
Pair(envelope.build(), rawMessageAsJSON["hash"] as? String)
}
else Pair(MessageWrapper.unwrap(data), rawMessageAsJSON["hash"] as? String)
} catch (e: Exception) {
Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.")
null
}
} else {
Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.")
null
}
}
}
@Suppress("UNCHECKED_CAST")

View File

@@ -24,10 +24,14 @@ data class SnodeMessage(
internal fun toJSON(): Map<String, String> {
return mapOf(
"pubKey" to recipient,
"pubkey" to recipient,
"data" to data,
"ttl" to ttl.toString(),
"timestamp" to timestamp.toString(),
)
}
companion object {
const val CONFIG_TTL: Long = 30 * 24 * 60 * 60 * 1000L
}
}

View File

@@ -0,0 +1,23 @@
package org.session.libsession.snode
import org.session.libsignal.utilities.AccountId
/**
* An interface that represents the necessary data to sign a message for accounts.
*
*/
interface SwarmAuth {
/**
* Sign the given data and return the signature JSON structure.
*/
fun sign(data: ByteArray): Map<String, String>
/**
* Sign the given data and return the signature JSON structure.
* This variant is used for push registry requests.
*/
fun signForPushRegistry(data: ByteArray): Map<String, String>
val accountId: AccountId
val ed25519PublicKeyHex: String?
}

View File

@@ -0,0 +1,13 @@
package org.session.libsession.snode.model
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
data class BatchResponse @JsonCreator constructor(
@param:JsonProperty("results") val results: List<Item>,
) {
data class Item @JsonCreator constructor(
@param:JsonProperty("code") val code: Int,
@param:JsonProperty("body") val body: Map<String, Any?>?,
)
}

View File

@@ -1,6 +1,11 @@
package org.session.libsession.snode.utilities
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@@ -10,4 +15,44 @@ suspend fun <T, E: Throwable> Promise<T, E>.await(): T {
success(cont::resume)
fail(cont::resumeWithException)
}
}
fun <T> CoroutineScope.asyncPromise(block: suspend () -> T): Promise<T, Exception> {
val defer = deferred<T, Exception>()
launch {
try {
defer.resolve(block())
} catch (e: Exception) {
defer.reject(e)
}
}
return defer.promise
}
fun <T> CoroutineScope.retrySuspendAsPromise(
maxRetryCount: Int,
retryIntervalMills: Long = 1_000L,
body: suspend () -> T
): Promise<T, Exception> {
return asyncPromise {
var retryCount = 0
while (true) {
try {
return@asyncPromise body()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (retryCount == maxRetryCount) {
throw e
} else {
retryCount += 1
delay(retryIntervalMills)
}
}
}
@Suppress("UNREACHABLE_CODE")
throw IllegalStateException("Unreachable code")
}
}

View File

@@ -19,9 +19,11 @@ class Address private constructor(address: String) : Parcelable, Comparable<Addr
constructor(`in`: Parcel) : this(`in`.readString()!!) {}
val isGroup: Boolean
get() = GroupUtil.isEncodedGroup(address)
val isClosedGroup: Boolean
get() = GroupUtil.isClosedGroup(address)
get() = GroupUtil.isEncodedGroup(address) || address.startsWith(IdPrefix.GROUP.value)
val isLegacyClosedGroup: Boolean
get() = GroupUtil.isLegacyClosedGroup(address)
val isClosedGroupV2: Boolean
get() = address.startsWith(IdPrefix.GROUP.value)
val isCommunity: Boolean
get() = GroupUtil.isCommunity(address)
val isCommunityInbox: Boolean

View File

@@ -1,23 +1,82 @@
package org.session.libsession.utilities
import kotlinx.coroutines.flow.Flow
import network.loki.messenger.libsession_util.Config
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.GroupInfoConfig
import network.loki.messenger.libsession_util.GroupKeysConfig
import network.loki.messenger.libsession_util.GroupMembersConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import org.session.libsession.messaging.messages.Destination
import org.session.libsignal.utilities.AccountId
interface ConfigFactoryProtocol {
val user: UserProfile?
val contacts: Contacts?
val convoVolatile: ConversationVolatileConfig?
val userGroups: UserGroupsConfig?
val configUpdateNotifications: Flow<Unit>
fun getGroupInfoConfig(groupSessionId: AccountId): GroupInfoConfig?
fun getGroupMemberConfig(groupSessionId: AccountId): GroupMembersConfig?
fun getGroupKeysConfig(groupSessionId: AccountId,
info: GroupInfoConfig? = null,
members: GroupMembersConfig? = null,
free: Boolean = true): GroupKeysConfig?
fun getUserConfigs(): List<ConfigBase>
fun persist(forConfigObject: ConfigBase, timestamp: Long)
fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String? = null)
fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean
fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean
fun saveGroupConfigs(
groupKeys: GroupKeysConfig,
groupInfo: GroupInfoConfig,
groupMembers: GroupMembersConfig
)
fun removeGroup(closedGroupId: AccountId)
fun scheduleUpdate(destination: Destination)
fun constructGroupKeysConfig(
groupSessionId: AccountId,
info: GroupInfoConfig,
members: GroupMembersConfig
): GroupKeysConfig?
fun maybeDecryptForUser(encoded: ByteArray,
domain: String,
closedGroupSessionId: AccountId): ByteArray?
fun userSessionId(): AccountId?
}
interface ConfigFactoryUpdateListener {
fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long)
fun notifyUpdates(forConfigObject: Config, messageTimestamp: Long)
}
/**
* Access group configs if they exist, otherwise return null.
*
* Note: The config objects will be closed after the callback is executed. Any attempt
* to store the config objects will result in a native crash.
*/
inline fun <T: Any> ConfigFactoryProtocol.withGroupConfigsOrNull(
groupId: AccountId,
cb: (GroupInfoConfig, GroupMembersConfig, GroupKeysConfig) -> T
): T? {
getGroupInfoConfig(groupId)?.use { groupInfo ->
getGroupMemberConfig(groupId)?.use { groupMembers ->
getGroupKeysConfig(groupId, groupInfo, groupMembers)?.use { groupKeys ->
return cb(groupInfo, groupMembers, groupKeys)
}
}
}
return null
}

View File

@@ -23,8 +23,8 @@ class GroupRecord(
val isOpenGroup: Boolean
get() = Address.fromSerialized(encodedId).isCommunity
val isClosedGroup: Boolean
get() = Address.fromSerialized(encodedId).isClosedGroup
val isLegacyClosedGroup: Boolean
get() = Address.fromSerialized(encodedId).isLegacyClosedGroup
init {
if (!TextUtils.isEmpty(members)) {

View File

@@ -1,13 +1,14 @@
package org.session.libsession.utilities
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import java.io.IOException
object GroupUtil {
const val CLOSED_GROUP_PREFIX = "__textsecure_group__!"
const val LEGACY_CLOSED_GROUP_PREFIX = "__textsecure_group__!"
const val COMMUNITY_PREFIX = "__loki_public_chat_group__!"
const val COMMUNITY_INBOX_PREFIX = "__open_group_inbox__!"
@@ -30,7 +31,9 @@ object GroupUtil {
@JvmStatic
fun getEncodedClosedGroupID(groupID: ByteArray): String {
return CLOSED_GROUP_PREFIX + Hex.toStringCondensed(groupID)
val hex = Hex.toStringCondensed(groupID)
if (hex.startsWith(IdPrefix.GROUP.value)) throw IllegalArgumentException("Trying to encode a new closed group")
return LEGACY_CLOSED_GROUP_PREFIX + hex
}
@JvmStatic
@@ -69,7 +72,7 @@ object GroupUtil {
}
fun isEncodedGroup(groupId: String): Boolean {
return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(COMMUNITY_PREFIX)
return groupId.startsWith(LEGACY_CLOSED_GROUP_PREFIX) || groupId.startsWith(COMMUNITY_PREFIX)
}
@JvmStatic
@@ -83,8 +86,8 @@ object GroupUtil {
}
@JvmStatic
fun isClosedGroup(groupId: String): Boolean {
return groupId.startsWith(CLOSED_GROUP_PREFIX)
fun isLegacyClosedGroup(groupId: String): Boolean {
return groupId.startsWith(LEGACY_CLOSED_GROUP_PREFIX)
}
// NOTE: Signal group ID handling is weird. The ID is double encoded in the database, but not in a `GroupContext`.
@@ -92,6 +95,7 @@ object GroupUtil {
@JvmStatic
@Throws(IOException::class)
fun doubleEncodeGroupID(groupPublicKey: String): String {
if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) throw IllegalArgumentException("Trying to double encode a new closed group")
return getEncodedClosedGroupID(getEncodedClosedGroupID(Hex.fromStringCondensed(groupPublicKey)).toByteArray())
}

View File

@@ -1,9 +1,7 @@
package org.session.libsession.utilities
import android.content.Context
import android.util.Log
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage

View File

@@ -11,6 +11,8 @@ import androidx.preference.PreferenceManager.getDefaultSharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.session.libsession.R
import org.session.libsession.utilities.TextSecurePreferences.Companion
@@ -43,12 +45,10 @@ interface TextSecurePreferences {
fun setLastConfigurationSyncTime(value: Long)
fun getConfigurationMessageSynced(): Boolean
fun setConfigurationMessageSynced(value: Boolean)
fun isPushEnabled(): Boolean
fun setPushEnabled(value: Boolean)
fun getPushToken(): String?
fun setPushToken(value: String)
fun getPushRegisterTime(): Long
fun setPushRegisterTime(value: Long)
val pushEnabled: StateFlow<Boolean>
fun isScreenLockEnabled(): Boolean
fun setScreenLockEnabled(value: Boolean)
fun getScreenLockTimeout(): Long
@@ -265,8 +265,6 @@ interface TextSecurePreferences {
const val GIF_METADATA_WARNING = "has_seen_gif_metadata_warning"
const val GIF_GRID_LAYOUT = "pref_gif_grid_layout"
val IS_PUSH_ENABLED get() = "pref_is_using_fcm$pushSuffix"
val PUSH_TOKEN get() = "pref_fcm_token_2$pushSuffix"
val PUSH_REGISTER_TIME get() = "pref_last_fcm_token_upload_time_2$pushSuffix"
const val LAST_CONFIGURATION_SYNC_TIME = "pref_last_configuration_sync_time"
const val CONFIGURATION_SYNCED = "pref_configuration_synced"
const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time"
@@ -341,24 +339,6 @@ interface TextSecurePreferences {
setBooleanPreference(context, IS_PUSH_ENABLED, value)
}
@JvmStatic
fun getPushToken(context: Context): String? {
return getStringPreference(context, PUSH_TOKEN, "")
}
@JvmStatic
fun setPushToken(context: Context, value: String?) {
setStringPreference(context, PUSH_TOKEN, value)
}
fun getPushRegisterTime(context: Context): Long {
return getLongPreference(context, PUSH_REGISTER_TIME, 0)
}
fun setPushRegisterTime(context: Context, value: Long) {
setLongPreference(context, PUSH_REGISTER_TIME, value)
}
// endregion
@JvmStatic
fun isScreenLockEnabled(context: Context): Boolean {
@@ -1032,28 +1012,13 @@ class AppTextSecurePreferences @Inject constructor(
TextSecurePreferences._events.tryEmit(TextSecurePreferences.CONFIGURATION_SYNCED)
}
override fun isPushEnabled(): Boolean {
return getBooleanPreference(TextSecurePreferences.IS_PUSH_ENABLED, false)
}
override val pushEnabled: MutableStateFlow<Boolean> = MutableStateFlow(
getBooleanPreference(TextSecurePreferences.IS_PUSH_ENABLED, false)
)
override fun setPushEnabled(value: Boolean) {
setBooleanPreference(TextSecurePreferences.IS_PUSH_ENABLED, value)
}
override fun getPushToken(): String? {
return getStringPreference(TextSecurePreferences.PUSH_TOKEN, "")
}
override fun setPushToken(value: String) {
setStringPreference(TextSecurePreferences.PUSH_TOKEN, value)
}
override fun getPushRegisterTime(): Long {
return getLongPreference(TextSecurePreferences.PUSH_REGISTER_TIME, 0)
}
override fun setPushRegisterTime(value: Long) {
setLongPreference(TextSecurePreferences.PUSH_REGISTER_TIME, value)
pushEnabled.value = value
}
override fun isScreenLockEnabled(): Boolean {

View File

@@ -0,0 +1,7 @@
package org.session.libsession.utilities
import androidx.annotation.StringRes
fun interface Toaster {
fun toast(@StringRes stringRes: Int, toastLength: Int, parameters: Map<String, String>)
}

View File

@@ -79,19 +79,20 @@ public class Recipient implements RecipientModifiedListener {
private @Nullable Uri systemContactPhoto;
private @Nullable Long groupAvatarId;
private Uri contactUri;
private @Nullable Uri messageRingtone = null;
private @Nullable Uri callRingtone = null;
public long mutedUntil = 0;
public int notifyType = 0;
private boolean blocked = false;
private boolean approved = false;
private boolean approvedMe = false;
private @Nullable Uri messageRingtone = null;
private @Nullable Uri callRingtone = null;
public long mutedUntil = 0;
public int notifyType = 0;
private boolean autoDownloadAttachments = false;
private boolean blocked = false;
private boolean approved = false;
private boolean approvedMe = false;
private DisappearingState disappearingState = null;
private VibrateState messageVibrate = VibrateState.DEFAULT;
private VibrateState callVibrate = VibrateState.DEFAULT;
private int expireMessages = 0;
private Optional<Integer> defaultSubscriptionId = Optional.absent();
private @NonNull RegisteredState registered = RegisteredState.UNKNOWN;
private VibrateState messageVibrate = VibrateState.DEFAULT;
private VibrateState callVibrate = VibrateState.DEFAULT;
private int expireMessages = 0;
private Optional<Integer> defaultSubscriptionId = Optional.absent();
private @NonNull RegisteredState registered = RegisteredState.UNKNOWN;
private @Nullable MaterialColor color;
private @Nullable byte[] profileKey;
@@ -165,36 +166,38 @@ public class Recipient implements RecipientModifiedListener {
this.forceSmsSelection = stale.forceSmsSelection;
this.notifyType = stale.notifyType;
this.disappearingState = stale.disappearingState;
this.autoDownloadAttachments = stale.autoDownloadAttachments;
this.participants.clear();
this.participants.addAll(stale.participants);
}
if (details.isPresent()) {
this.name = details.get().name;
this.systemContactPhoto = details.get().systemContactPhoto;
this.groupAvatarId = details.get().groupAvatarId;
this.isLocalNumber = details.get().isLocalNumber;
this.color = details.get().color;
this.messageRingtone = details.get().messageRingtone;
this.callRingtone = details.get().callRingtone;
this.mutedUntil = details.get().mutedUntil;
this.blocked = details.get().blocked;
this.approved = details.get().approved;
this.approvedMe = details.get().approvedMe;
this.messageVibrate = details.get().messageVibrateState;
this.callVibrate = details.get().callVibrateState;
this.expireMessages = details.get().expireMessages;
this.defaultSubscriptionId = details.get().defaultSubscriptionId;
this.registered = details.get().registered;
this.notificationChannel = details.get().notificationChannel;
this.profileKey = details.get().profileKey;
this.profileName = details.get().profileName;
this.profileAvatar = details.get().profileAvatar;
this.profileSharing = details.get().profileSharing;
this.unidentifiedAccessMode = details.get().unidentifiedAccessMode;
this.forceSmsSelection = details.get().forceSmsSelection;
this.notifyType = details.get().notifyType;
this.name = details.get().name;
this.systemContactPhoto = details.get().systemContactPhoto;
this.groupAvatarId = details.get().groupAvatarId;
this.isLocalNumber = details.get().isLocalNumber;
this.color = details.get().color;
this.messageRingtone = details.get().messageRingtone;
this.callRingtone = details.get().callRingtone;
this.mutedUntil = details.get().mutedUntil;
this.blocked = details.get().blocked;
this.approved = details.get().approved;
this.approvedMe = details.get().approvedMe;
this.messageVibrate = details.get().messageVibrateState;
this.callVibrate = details.get().callVibrateState;
this.expireMessages = details.get().expireMessages;
this.defaultSubscriptionId = details.get().defaultSubscriptionId;
this.registered = details.get().registered;
this.notificationChannel = details.get().notificationChannel;
this.profileKey = details.get().profileKey;
this.profileName = details.get().profileName;
this.profileAvatar = details.get().profileAvatar;
this.profileSharing = details.get().profileSharing;
this.unidentifiedAccessMode = details.get().unidentifiedAccessMode;
this.forceSmsSelection = details.get().forceSmsSelection;
this.notifyType = details.get().notifyType;
this.autoDownloadAttachments = details.get().autoDownloadAttachments;
this.blocksCommunityMessageRequests = details.get().blocksCommunityMessageRequests;
this.disappearingState = details.get().disappearingState;
@@ -234,8 +237,10 @@ public class Recipient implements RecipientModifiedListener {
Recipient.this.forceSmsSelection = result.forceSmsSelection;
Recipient.this.notifyType = result.notifyType;
Recipient.this.disappearingState = result.disappearingState;
Recipient.this.autoDownloadAttachments = result.autoDownloadAttachments;
Recipient.this.blocksCommunityMessageRequests = result.blocksCommunityMessageRequests;
Recipient.this.participants.clear();
Recipient.this.participants.addAll(result.participants);
Recipient.this.resolving = false;
@@ -272,6 +277,7 @@ public class Recipient implements RecipientModifiedListener {
this.callRingtone = details.callRingtone;
this.mutedUntil = details.mutedUntil;
this.notifyType = details.notifyType;
this.autoDownloadAttachments = details.autoDownloadAttachments;
this.blocked = details.blocked;
this.approved = details.approved;
this.approvedMe = details.approvedMe;
@@ -471,10 +477,15 @@ public class Recipient implements RecipientModifiedListener {
return address.isCommunityInbox();
}
public boolean isClosedGroupRecipient() {
return address.isClosedGroup();
public boolean isLegacyClosedGroupRecipient() {
return address.isLegacyClosedGroup();
}
public boolean isClosedGroupV2Recipient() {
return address.isClosedGroupV2();
}
@Deprecated
public boolean isPushGroupRecipient() {
return address.isGroup();
@@ -614,6 +625,18 @@ public class Recipient implements RecipientModifiedListener {
notifyListeners();
}
public boolean getAutoDownloadAttachments() {
return autoDownloadAttachments;
}
public void setAutoDownloadAttachments(boolean autoDownloadAttachments) {
synchronized (this) {
this.autoDownloadAttachments = autoDownloadAttachments;
}
notifyListeners();
}
public synchronized boolean isBlocked() {
return blocked;
}
@@ -935,6 +958,7 @@ public class Recipient implements RecipientModifiedListener {
private final boolean approvedMe;
private final long muteUntil;
private final int notifyType;
private final boolean autoDownloadAttachments;
private final DisappearingState disappearingState;
private final VibrateState messageVibrateState;
private final VibrateState callVibrateState;
@@ -960,56 +984,58 @@ public class Recipient implements RecipientModifiedListener {
public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, long muteUntil,
int notifyType,
@NonNull DisappearingState disappearingState,
boolean autoDownloadAttachments,
@NonNull DisappearingState disappearingState,
@NonNull VibrateState messageVibrateState,
@NonNull VibrateState callVibrateState,
@Nullable Uri messageRingtone,
@Nullable Uri callRingtone,
@Nullable MaterialColor color,
int defaultSubscriptionId,
int expireMessages,
@NonNull RegisteredState registered,
@Nullable byte[] profileKey,
@Nullable String systemDisplayName,
@Nullable String systemContactPhoto,
@Nullable String systemPhoneLabel,
@Nullable String systemContactUri,
@Nullable String signalProfileName,
@Nullable String signalProfileAvatar,
boolean profileSharing,
@Nullable String notificationChannel,
@NonNull UnidentifiedAccessMode unidentifiedAccessMode,
boolean forceSmsSelection,
String wrapperHash,
@NonNull VibrateState callVibrateState,
@Nullable Uri messageRingtone,
@Nullable Uri callRingtone,
@Nullable MaterialColor color,
int defaultSubscriptionId,
int expireMessages,
@NonNull RegisteredState registered,
@Nullable byte[] profileKey,
@Nullable String systemDisplayName,
@Nullable String systemContactPhoto,
@Nullable String systemPhoneLabel,
@Nullable String systemContactUri,
@Nullable String signalProfileName,
@Nullable String signalProfileAvatar,
boolean profileSharing,
@Nullable String notificationChannel,
@NonNull UnidentifiedAccessMode unidentifiedAccessMode,
boolean forceSmsSelection,
String wrapperHash,
boolean blocksCommunityMessageRequests
)
{
this.blocked = blocked;
this.approved = approved;
this.approvedMe = approvedMe;
this.muteUntil = muteUntil;
this.notifyType = notifyType;
this.blocked = blocked;
this.approved = approved;
this.approvedMe = approvedMe;
this.muteUntil = muteUntil;
this.notifyType = notifyType;
this.autoDownloadAttachments = autoDownloadAttachments;
this.disappearingState = disappearingState;
this.messageVibrateState = messageVibrateState;
this.callVibrateState = callVibrateState;
this.messageRingtone = messageRingtone;
this.callRingtone = callRingtone;
this.color = color;
this.defaultSubscriptionId = defaultSubscriptionId;
this.expireMessages = expireMessages;
this.registered = registered;
this.profileKey = profileKey;
this.systemDisplayName = systemDisplayName;
this.systemContactPhoto = systemContactPhoto;
this.systemPhoneLabel = systemPhoneLabel;
this.systemContactUri = systemContactUri;
this.signalProfileName = signalProfileName;
this.signalProfileAvatar = signalProfileAvatar;
this.profileSharing = profileSharing;
this.notificationChannel = notificationChannel;
this.unidentifiedAccessMode = unidentifiedAccessMode;
this.forceSmsSelection = forceSmsSelection;
this.wrapperHash = wrapperHash;
this.messageVibrateState = messageVibrateState;
this.callVibrateState = callVibrateState;
this.messageRingtone = messageRingtone;
this.callRingtone = callRingtone;
this.color = color;
this.defaultSubscriptionId = defaultSubscriptionId;
this.expireMessages = expireMessages;
this.registered = registered;
this.profileKey = profileKey;
this.systemDisplayName = systemDisplayName;
this.systemContactPhoto = systemContactPhoto;
this.systemPhoneLabel = systemPhoneLabel;
this.systemContactUri = systemContactUri;
this.signalProfileName = signalProfileName;
this.signalProfileAvatar = signalProfileAvatar;
this.profileSharing = profileSharing;
this.notificationChannel = notificationChannel;
this.unidentifiedAccessMode = unidentifiedAccessMode;
this.forceSmsSelection = forceSmsSelection;
this.wrapperHash = wrapperHash;
this.blocksCommunityMessageRequests = blocksCommunityMessageRequests;
}
@@ -1041,6 +1067,10 @@ public class Recipient implements RecipientModifiedListener {
return disappearingState;
}
public boolean getAutoDownloadAttachments() {
return autoDownloadAttachments;
}
public @NonNull VibrateState getMessageVibrateState() {
return messageVibrateState;
}

View File

@@ -104,7 +104,7 @@ class RecipientProvider {
}
private @NonNull RecipientDetails getRecipientDetailsSync(Context context, @NonNull Address address, Optional<RecipientSettings> settings, Optional<GroupRecord> groupRecord, boolean nestedAsynchronous) {
if (address.isGroup()) return getGroupRecipientDetails(context, address, groupRecord, settings, nestedAsynchronous);
if (address.isGroup() && !address.isClosedGroupV2()) return getGroupRecipientDetails(context, address, groupRecord, settings, nestedAsynchronous);
else return getIndividualRecipientDetails(context, address, settings);
}
@@ -161,6 +161,7 @@ class RecipientProvider {
final long mutedUntil;
final int notifyType;
@Nullable final DisappearingState disappearingState;
final boolean autoDownloadAttachments;
@Nullable final VibrateState messageVibrateState;
@Nullable final VibrateState callVibrateState;
final boolean blocked;
@@ -195,6 +196,7 @@ class RecipientProvider {
this.callRingtone = settings != null ? settings.getCallRingtone() : null;
this.mutedUntil = settings != null ? settings.getMuteUntil() : 0;
this.notifyType = settings != null ? settings.getNotifyType() : 0;
this.autoDownloadAttachments = settings != null && settings.getAutoDownloadAttachments();
this.disappearingState = settings != null ? settings.getDisappearingState() : null;
this.messageVibrateState = settings != null ? settings.getMessageVibrateState() : null;
this.callVibrateState = settings != null ? settings.getCallVibrateState() : null;

View File

@@ -78,8 +78,6 @@
<dimen name="conversation_compose_height">40dp</dimen>
<integer name="media_overview_cols">3</integer>
<dimen name="contact_selection_actions_tap_area">10dp</dimen>
<dimen name="unread_count_bubble_radius">13sp</dimen>