mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-11 15:17:43 +00:00
Strings work
Squashed commit of the following: commit86cab0e11e
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 commit706d1aadd8
Author: ThomasSession <thomas.r@getsession.org> Date: Fri Aug 30 09:49:48 2024 +1000 fixing up clear data dialog Removing unused code commitf90599451f
Author: Al Lansley <al@oxen.io> Date: Fri Aug 30 09:13:51 2024 +1000 Replaced 'now' with 12/24 hour time commit16b8ad46c0
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 17:34:03 2024 +1000 Fix two one-liner issues commit4c6c450b32
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 commit052f910d69
Author: ThomasSession <thomas.r@getsession.org> Date: Thu Aug 29 17:06:53 2024 +1000 More bold fixing commitbeb89d5b74
Author: fanchao <git@fanchao.dev> Date: Thu Aug 29 17:00:37 2024 +1000 Fix incorrect group member left message commit5773f05a5c
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 commitd35482daba
Author: ThomasSession <thomas.r@getsession.org> Date: Thu Aug 29 15:20:13 2024 +1000 More bold fixes and UI tweaks commit78a9ab7159
Author: ThomasSession <thomas.r@getsession.org> Date: Thu Aug 29 14:03:41 2024 +1000 Making sure we bold appropriately commit1cec477020
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 commit8e80ab08a9
Author: ThomasSession <thomas.r@getsession.org> Date: Thu Aug 29 13:28:54 2024 +1000 Using the existing implementation commitcb9554ab38
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 12:32:30 2024 +1000 Merge CrowdIn strings circa 2024-08-29 commitdd57da70f6
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 09:06:22 2024 +1000 Updated Phrase usage in ConversationAdapter commit34b15d7865
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 09:03:55 2024 +1000 Converted TransferControlView into Kotlin and updated Phrase usage commita35a7a6a96
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 08:55:16 2024 +1000 Converted MessageReceipientNotificationBuilder to Kotlin & updated Phrase usage commit6dd93b33f2
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 08:25:24 2024 +1000 Update MuteDialog, LinkPreviewDialog, and PathActivity commite7dd1c582d
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 08:16:09 2024 +1000 Updated DisappearingMessages.kt and HelpSettingsActivity.kt commit5bd55ea993
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 commitd3fb440d05
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 07:15:03 2024 +1000 Removed R.string.gif and replaced with a string constant commitace58e3493
Author: alansley <aclansley@gmail.com> Date: Thu Aug 29 07:11:53 2024 +1000 getSubbedString correction commit2a8f010369
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 commitce8efd7def
Author: alansley <aclansley@gmail.com> Date: Wed Aug 28 16:31:11 2024 +1000 WIP commit114066ad5f
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 commit116bef3c71
Author: ThomasSession <thomas.r@getsession.org> Date: Wed Aug 28 15:25:03 2024 +1000 For safety commit0b1a71a582
Author: ThomasSession <thomas.r@getsession.org> Date: Wed Aug 28 15:23:02 2024 +1000 Cleaning other use of old url dialog commit20abbebf4a
Author: ThomasSession <thomas.r@getsession.org> Date: Wed Aug 28 15:19:46 2024 +1000 Forgot !! commit25132c6342
Author: ThomasSession <thomas.r@getsession.org> Date: Wed Aug 28 15:13:58 2024 +1000 Proper set up for the Open URL dialog commit1f68791da9
Author: alansley <aclansley@gmail.com> Date: Wed Aug 28 14:35:05 2024 +1000 Replaced placeholder text with new string commit8d97f31b4d
Author: alansley <aclansley@gmail.com> Date: Wed Aug 28 14:31:52 2024 +1000 Adjusted comment commitdfebe6f3f9
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 commit736b5313e6
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 commit413bc0be4b
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 commitae7164ecbb
Merge:5df981bc7a
d1c4283f42
Author: alansley <aclansley@gmail.com> Date: Wed Aug 28 09:51:58 2024 +1000 Merge branch 'dev' into strings-squashed commit2aa58f4dd6
Author: alansley <aclansley@gmail.com> Date: Wed Aug 28 08:27:03 2024 +1000 WIP compose openURL dialog commit5df981bc7a
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 commit96453f1f1e
Author: alansley <aclansley@gmail.com> Date: Tue Aug 27 15:42:33 2024 +1000 Added some TODO markers for tomorrow commita402a1be79
Author: alansley <aclansley@gmail.com> Date: Tue Aug 27 15:33:55 2024 +1000 Adjusted Landing page string substitutions to cater for emojis commit4809b5444b
Author: alansley <aclansley@gmail.com> Date: Tue Aug 27 15:12:39 2024 +1000 Removed unused 'isEmpty' utility methods commitb52048a080
Author: alansley <aclansley@gmail.com> Date: Tue Aug 27 14:42:57 2024 +1000 Addressed many aspects of PR feedback + misc. strings issues commit9cdbc4b80b
Author: alansley <aclansley@gmail.com> Date: Tue Aug 27 09:50:51 2024 +1000 Adjusted strings as per Rebecca's 'String Changes' spreadsheet commit4d7e4b9e2c
Merge:3c576053a3
1393335121
Author: alansley <aclansley@gmail.com> Date: Tue Aug 27 08:19:53 2024 +1000 Merge branch 'dev' into strings-squashed commit3c576053a3
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 commitb908a54a44
Merge:404fb8001c
bfbe4a8fd2
Author: alansley <aclansley@gmail.com> Date: Mon Aug 26 11:54:09 2024 +1000 Merge branch 'dev' into strings-squashed commit404fb8001c
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 commit53978f818d
Author: Al Lansley <al@oxen.io> Date: Fri Aug 23 14:13:11 2024 +1000 Cleaned up HomeActivityTests.kt commit5f82571bef
Merge:69b8bd7396
8deb21c0c6
Author: Al Lansley <al@oxen.io> Date: Fri Aug 23 08:59:21 2024 +1000 Merge branch 'dev' into strings-squashed commit69b8bd7396
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 commite3cab9c0d9
Author: alansley <aclansley@gmail.com> Date: Thu Aug 22 14:26:48 2024 +1000 SS-75 Prevented ScrollView vertical scroll bar from fading out commitb0b835092d
Author: alansley <aclansley@gmail.com> Date: Thu Aug 22 14:07:49 2024 +1000 SS-64 Removed all 'Unblocked {name}' toasts as per instructions commitc3c35de408
Merge:efc2ee2824
8e10e1abf4
Author: alansley <aclansley@gmail.com> Date: Thu Aug 22 13:43:00 2024 +1000 Merge branch 'dev' into strings-squashed commitefc2ee2824
Author: alansley <aclansley@gmail.com> Date: Thu Aug 22 13:40:59 2024 +1000 Added some comments about the new CrowdIn strings commit7a03fb37ef
Author: alansley <aclansley@gmail.com> Date: Thu Aug 22 13:08:03 2024 +1000 Initial integration of CrowdIn strings (English only) commit9766c3fd0b
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 commit59b4805b8b
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 commitb7f627f03c
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 commit69f6818f99
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 commit2192c2c007
Merge:2338bb47ca
eea54d1a17
Author: alansley <aclansley@gmail.com> Date: Wed Aug 21 13:28:16 2024 +1000 Merge branch 'dev' into strings-squashed commit2338bb47ca
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 commit6b29e4d8ce
Author: alansley <aclansley@gmail.com> Date: Tue Aug 20 17:53:27 2024 +1000 Added a note about the plurals for search results commitf7748a0c05
Author: alansley <aclansley@gmail.com> Date: Tue Aug 20 16:06:24 2024 +1000 Corrected text on storage permission dialog commitf6b6256598
Author: alansley <aclansley@gmail.com> Date: Tue Aug 20 14:44:25 2024 +1000 Minor cleanup of BlockedContactsActivity commite3d4870d81
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 commite812527358
Merge:5e02e1ef5c
9919f716a7
Author: alansley <aclansley@gmail.com> Date: Tue Aug 20 13:27:35 2024 +1000 Merge branch 'dev' into strings-squashed commit5e02e1ef5c
Author: alansley <aclansley@gmail.com> Date: Tue Aug 20 09:39:16 2024 +1000 Added 'NonTranslatableStringConstants' file commit816f21bb29
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' commitacc8d47c68
Author: Al Lansley <al@oxen.io> Date: Mon Aug 19 16:22:08 2024 +1000 SES-1571 Large messages show warning toast commit27ca77d5c4
Merge:27bc90bf1f
f379604c54
Author: Al Lansley <al@oxen.io> Date: Mon Aug 19 11:19:27 2024 +1000 Merge branch 'dev' into strings-squashed commit27bc90bf1f
Author: Al Lansley <al@oxen.io> Date: Mon Aug 19 08:59:38 2024 +1000 Cleaned up some comments and content description commit558684a56d
Merge:90d7064c18
93a28906fb
Author: Al Lansley <al@oxen.io> Date: Mon Aug 19 08:41:47 2024 +1000 Merge branch 'dev' into strings-squashed commit90d7064c18
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 commit51ef0ec81c
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') commiteecce08c25
Merge:01009cf521
5a248da445
Author: Al Lansley <al@oxen.io> Date: Thu Aug 15 09:38:10 2024 +1000 Merge branch 'dev' into strings-squashed commit01009cf521
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) commit9441d1e08d
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 commit6cd6cc3e26
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 commitedd154d8e1
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 commita8ee5c9f3b
Author: alansley <aclansley@gmail.com> Date: Wed Aug 14 14:51:40 2024 +1000 Replaced hard-coded 'Session' with '{app_name}' in 'callsSessionCall' commit621094ebe4
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 commit0c83606539
Author: alansley <aclansley@gmail.com> Date: Tue Aug 13 15:50:35 2024 +1000 SS-75 Open URL modal change commit802cf19598
Author: Al Lansley <al@oxen.io> Date: Mon Aug 12 16:42:15 2024 +1000 Open or copy URL WIP commitea84aa1478
Author: Al Lansley <al@oxen.io> Date: Mon Aug 12 14:17:04 2024 +1000 Tied in bandDeleteAll string commit93b8e74f2d
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! commitfc3b4ad367
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 commit558d6741b1
Author: alansley <aclansley@gmail.com> Date: Fri Aug 9 17:24:44 2024 +1000 End of day push commit73fdb16214
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 commit436175d146
Author: alansley <aclansley@gmail.com> Date: Fri Aug 9 13:54:09 2024 +1000 Relative time string WIP commitf309263e39
Merge:45c4118d52
007e705cd9
Author: alansley <aclansley@gmail.com> Date: Fri Aug 9 11:39:13 2024 +1000 Merge dev commit45c4118d52
Author: Al Lansley <al@oxen.io> Date: Thu Aug 8 16:43:02 2024 +1000 Further AccessibilityId mapping WIP commit31bac8e30e
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 commit9c2111e66e
Author: alansley <aclansley@gmail.com> Date: Wed Aug 7 13:13:52 2024 +1000 AccessibilityId WIP commit1e9eeff86a
Author: alansley <aclansley@gmail.com> Date: Wed Aug 7 11:06:39 2024 +1000 AccessibilityId adjustments & removed some unused XML layouts commite5fd2c8cc0
Author: alansley <aclansley@gmail.com> Date: Wed Aug 7 09:22:14 2024 +1000 AccessibilityId refactor WIP commit399796bac3
Author: alansley <aclansley@gmail.com> Date: Tue Aug 6 15:51:53 2024 +1000 AccessibilityId WIP - up to AccessibilityId_reveal_recovery_phrase_button commita8d72dfcc0
Author: alansley <aclansley@gmail.com> Date: Tue Aug 6 14:12:10 2024 +1000 Cleaned up a few comments and fixed some plurals logic commitbe400d8f4f
Author: alansley <aclansley@gmail.com> Date: Tue Aug 6 11:32:08 2024 +1000 Removed commented out merge conflict marker commit5cbe289a8d
Merge:5fe123e7b5
d6c5ab2b18
Author: alansley <aclansley@gmail.com> Date: Tue Aug 6 11:30:50 2024 +1000 Merge dev and cleanup commit5fe123e7b5
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 commitd3f8e928b6
Merge:00552930e6
cd1a0643e3
Author: Al Lansley <al@oxen.io> Date: Mon Aug 5 13:30:03 2024 +1000 Merge branch 'dev' into strings-squashed commit00552930e6
Author: Al Lansley <al@oxen.io> Date: Mon Aug 5 13:28:55 2024 +1000 Removed unused helpReportABugDesktop strings commit6c0450b487
Author: Al Lansley <al@oxen.io> Date: Mon Aug 5 12:59:15 2024 +1000 Renamed 'quitButton' string to just 'quit' commit284c485903
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 commit6948d64fa8
Author: Al Lansley <al@oxen.io> Date: Mon Aug 5 10:45:05 2024 +1000 WIP commitbc94cb78db
Author: alansley <aclansley@gmail.com> Date: Fri Aug 2 16:21:16 2024 +1000 End of day push commit1a2df3798a
Merge:c7fdb6aed9
a56e1d0b91
Author: alansley <aclansley@gmail.com> Date: Fri Aug 2 15:20:19 2024 +1000 Merged dev commitc7fdb6aed9
Author: alansley <aclansley@gmail.com> Date: Fri Aug 2 14:21:11 2024 +1000 Replaced string 'dialog_disappearing_messages_follow_setting_confirm' with 'confirm' commit2992d590d9
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 commit4218663c95
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 commitba2d0275e4
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 commit20662c8222
Merge:608c984a6b
fbbef4898a
Author: alansley <aclansley@gmail.com> Date: Wed Jul 31 13:08:04 2024 +1000 Merge branch 'dev' into strings-squashed commit608c984a6b
Author: alansley <aclansley@gmail.com> Date: Tue Jul 30 16:58:08 2024 +1000 Actually remove the 4 specific time period mute strings commit006a4e8bad
Author: alansley <aclansley@gmail.com> Date: Tue Jul 30 16:43:54 2024 +1000 Cleaned up MuteDialog.kt commitd3177f9f1a
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. commitd568a86649
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 commit84f6f19cf4
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 commitbc90d18c91
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 commit79cd87878c
Merge:3b62e474b3
dec02cef5a
Author: alansley <aclansley@gmail.com> Date: Tue Jul 30 08:16:02 2024 +1000 Merge branch 'dev' into strings-squashed commit3b62e474b3
Author: Al Lansley <al@oxen.io> Date: Mon Jul 29 16:33:21 2024 +1000 Down to just the final few straggler strings commit13e81f046b
Author: Al Lansley <al@oxen.io> Date: Mon Jul 29 13:13:54 2024 +1000 WIP commit2d9961d5c0
Author: Al Lansley <al@oxen.io> Date: Mon Jul 29 08:58:01 2024 +1000 Further cleanup of stragglers commit08b8a84309
Author: Al Lansley <al@oxen.io> Date: Mon Jul 29 08:29:12 2024 +1000 Cleaning up straggler strings commitd0e87c64b5
Author: alansley <aclansley@gmail.com> Date: Fri Jul 26 17:07:46 2024 +1000 WIP commit4bc9d09be2
Author: alansley <aclansley@gmail.com> Date: Fri Jul 26 16:30:28 2024 +1000 WIP commit3cee4bc12f
Merge:aa1db13e3a
a495ec232a
Author: alansley <aclansley@gmail.com> Date: Fri Jul 26 13:57:09 2024 +1000 Removed some legacy strings & substituted others commitaa1db13e3a
Author: fanchao <git@fanchao.dev> Date: Fri Jul 26 11:34:05 2024 +1000 Initial squash merge for strings
This commit is contained in:
@@ -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"
|
||||
|
@@ -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?
|
||||
|
@@ -0,0 +1,8 @@
|
||||
package org.session.libsession.database
|
||||
|
||||
data class ServerHashToMessageId(
|
||||
val serverHash: String,
|
||||
val sender: String,
|
||||
val messageId: Long,
|
||||
val isSms: Boolean,
|
||||
)
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
||||
}
|
@@ -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)
|
||||
|
@@ -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"
|
||||
}
|
||||
|
||||
}
|
@@ -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) {
|
||||
|
@@ -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.")
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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) {
|
||||
|
@@ -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
|
||||
|
@@ -20,6 +20,8 @@ class DataExtractionNotification() : ControlMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun shouldDiscardIfBlocked(): Boolean = true
|
||||
|
||||
companion object {
|
||||
const val TAG = "DataExtractionNotification"
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
@@ -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 }
|
||||
|
@@ -14,6 +14,8 @@ class ReadReceipt() : ControlMessage() {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun shouldDiscardIfBlocked(): Boolean = true
|
||||
|
||||
companion object {
|
||||
const val TAG = "ReadReceipt"
|
||||
|
||||
|
@@ -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? =
|
||||
|
@@ -14,6 +14,8 @@ class TypingIndicator() : ControlMessage() {
|
||||
return kind != null
|
||||
}
|
||||
|
||||
override fun shouldDiscardIfBlocked(): Boolean = true
|
||||
|
||||
companion object {
|
||||
const val TAG = "TypingIndicator"
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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()));
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
@@ -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 = ""
|
||||
|
@@ -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 {
|
||||
|
@@ -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())
|
||||
|
@@ -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())
|
||||
|
||||
|
@@ -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) {
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
}
|
@@ -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>) {
|
||||
|
@@ -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
|
||||
|
@@ -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() {
|
||||
|
@@ -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
|
||||
|
@@ -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 */
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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}")
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -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) }
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -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) {
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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")
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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?
|
||||
}
|
@@ -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?>?,
|
||||
)
|
||||
}
|
@@ -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")
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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
|
||||
}
|
@@ -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)) {
|
||||
|
@@ -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())
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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 {
|
||||
|
@@ -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>)
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user