Strings work

Squashed commit of the following:

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

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

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

    fixing up clear data dialog

    Removing unused code

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

    Replaced 'now' with 12/24 hour time

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

    Fix two one-liner issues

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

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

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

    More bold fixing

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

    Fix incorrect group member left message

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

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

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

    More bold fixes and UI tweaks

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

    Making sure we bold appropriately

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

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

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

    Using the existing implementation

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

    Merge CrowdIn strings circa 2024-08-29

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

    Updated Phrase usage in ConversationAdapter

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

    Converted TransferControlView into Kotlin and updated Phrase usage

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

    Converted MessageReceipientNotificationBuilder to Kotlin & updated Phrase usage

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

    Update MuteDialog, LinkPreviewDialog, and PathActivity

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

    Updated DisappearingMessages.kt and HelpSettingsActivity.kt

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

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

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

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

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

    getSubbedString correction

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

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

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

    WIP

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

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

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

    For safety

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

    Cleaning other use of old url dialog

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

    Forgot !!

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

    Proper set up for the Open URL dialog

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

    Replaced placeholder text with new string

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

    Adjusted comment

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

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

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

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

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

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

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

    Merge branch 'dev' into strings-squashed

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

    WIP compose openURL dialog

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

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

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

    Added some TODO markers for tomorrow

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

    Adjusted Landing page string substitutions to cater for emojis

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

    Removed unused 'isEmpty' utility methods

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

    Addressed many aspects of PR feedback + misc. strings issues

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

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

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

    Merge branch 'dev' into strings-squashed

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

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

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

    Merge branch 'dev' into strings-squashed

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

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

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

    Cleaned up HomeActivityTests.kt

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

    Merge branch 'dev' into strings-squashed

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

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

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

    SS-75 Prevented ScrollView vertical scroll bar from fading out

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

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

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

    Merge branch 'dev' into strings-squashed

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

    Added some comments about the new CrowdIn strings

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

    Initial integration of CrowdIn strings (English only)

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

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

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

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

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

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

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

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

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

    Merge branch 'dev' into strings-squashed

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

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

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

    Added a note about the plurals for search results

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

    Corrected text on storage permission dialog

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

    Minor cleanup of BlockedContactsActivity

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

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

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

    Merge branch 'dev' into strings-squashed

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

    Added 'NonTranslatableStringConstants' file

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

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

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

    SES-1571 Large messages show warning toast

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

    Merge branch 'dev' into strings-squashed

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

    Cleaned up some comments and content description

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

    Merge branch 'dev' into strings-squashed

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

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

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

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

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

    Merge branch 'dev' into strings-squashed

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

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

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

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

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

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

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

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

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

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

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

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

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

    SS-75 Open URL modal change

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

    Open or copy URL WIP

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

    Tied in bandDeleteAll string

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

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

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

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

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

    End of day push

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

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

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

    Relative time string WIP

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

    Merge dev

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

    Further AccessibilityId mapping WIP

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

    Further accessibility ID changes & removed fragment_new_conversation_home.xml

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

    AccessibilityId WIP

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

    AccessibilityId adjustments & removed some unused XML layouts

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

    AccessibilityId refactor WIP

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

    AccessibilityId WIP - up to AccessibilityId_reveal_recovery_phrase_button

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

    Cleaned up a few comments and fixed some plurals logic

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

    Removed commented out merge conflict marker

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

    Merge dev and cleanup

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

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

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

    Merge branch 'dev' into strings-squashed

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

    Removed unused helpReportABugDesktop strings

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

    Renamed 'quitButton' string to just 'quit'

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

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

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

    WIP

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

    End of day push

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

    Merged dev

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

    Replaced string 'dialog_disappearing_messages_follow_setting_confirm' with 'confirm'

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

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

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

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

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

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

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

    Merge branch 'dev' into strings-squashed

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

    Actually remove the 4 specific time period mute strings

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

    Cleaned up MuteDialog.kt

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

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

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

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

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

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

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

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

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

    Merge branch 'dev' into strings-squashed

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

    Down to just the final few straggler strings

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

    WIP

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

    Further cleanup of stragglers

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

    Cleaning up straggler strings

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

    WIP

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

    WIP

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

    Removed some legacy strings & substituted others

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

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

View File

@ -38,7 +38,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always', pull: 'always',
environment: { ANDROID_HOME: '/usr/lib/android-sdk' }, environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [ commands: [
'apt-get install -y ninja-build', 'apt-get install -y ninja-build openjdk-17-jdk-headless',
'./gradlew testPlayDebugUnitTestCoverageReport' './gradlew testPlayDebugUnitTestCoverageReport'
], ],
} }
@ -78,7 +78,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always', pull: 'always',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' }, environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [ commands: [
'apt-get install -y ninja-build', 'apt-get install -y ninja-build openjdk-17-jdk-headless',
'./gradlew assemblePlayDebug', './gradlew assemblePlayDebug',
'./scripts/drone-static-upload.sh' './scripts/drone-static-upload.sh'
], ],

10
.drone.yml Normal file
View File

@ -0,0 +1,10 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: test
image: mingc/android-build-box:1.24.0
commands:
- bash ./gradlew test

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
project.properties project.properties
.project .project
.settings .settings
.kotlin
bin/ bin/
gen/ gen/
.idea/ .idea/

View File

@ -1,16 +1,18 @@
plugins { plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose'
id 'com.google.devtools.ksp' id 'com.google.devtools.ksp'
id 'com.google.dagger.hilt.android' id 'com.google.dagger.hilt.android'
id 'kotlin-parcelize'
id 'kotlinx-serialization'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'witness' apply plugin: 'witness'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlinx-serialization'
configurations.forEach { configurations.configureEach {
it.exclude module: "commons-logging" exclude module: "commons-logging"
} }
def canonicalVersionCode = 380 def canonicalVersionCode = 380
@ -40,12 +42,12 @@ android {
useLibrary 'org.apache.http.legacy' useLibrary 'org.apache.http.legacy'
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '17'
} }
packagingOptions { packagingOptions {
@ -54,6 +56,7 @@ android {
} }
} }
splits { splits {
abi { abi {
enable true enable true
@ -64,7 +67,8 @@ android {
} }
buildFeatures { buildFeatures {
compose true viewBinding true
buildConfig true
} }
composeOptions { composeOptions {
@ -151,7 +155,7 @@ android {
} }
} }
applicationVariants.forEach { variant -> applicationVariants.configureEach { variant ->
variant.outputs.each { output -> variant.outputs.each { output ->
def abiName = output.getFilter("ABI") ?: 'universal' def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0) def postFix = abiPostFix.get(abiName, 0)
@ -169,11 +173,11 @@ android {
} }
} }
buildFeatures {
viewBinding true
}
def huaweiEnabled = project.properties['huawei'] != null def huaweiEnabled = project.properties['huawei'] != null
lint {
abortOnError true
baseline file('lint-baseline.xml')
}
applicationVariants.configureEach { variant -> applicationVariants.configureEach { variant ->
if (variant.flavorName == 'huawei') { if (variant.flavorName == 'huawei') {
@ -226,6 +230,7 @@ dependencies {
ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion") ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion")
ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion")
ksp("com.github.bumptech.glide:ksp:$glideVersion") ksp("com.github.bumptech.glide:ksp:$glideVersion")
implementation("androidx.hilt:hilt-navigation-compose:$androidxHiltVersion")
implementation("com.google.dagger:hilt-android:$daggerHiltVersion") implementation("com.google.dagger:hilt-android:$daggerHiltVersion")
implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion"
@ -305,7 +310,6 @@ dependencies {
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.phrase:phrase:$phraseVersion" implementation "com.squareup.phrase:phrase:$phraseVersion"
implementation 'app.cash.copper:copper-flow:1.0.0' implementation 'app.cash.copper:copper-flow:1.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
@ -329,7 +333,6 @@ dependencies {
androidTestImplementation('com.adevinta.android:barista:4.2.0') { androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin' exclude group: 'org.jetbrains.kotlin'
} }
// AndroidJUnitRunner and JUnit Rules // AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.5.0'
@ -348,6 +351,8 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1' androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3"
androidTestUtil 'androidx.test:orchestrator:1.4.2' androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.12.2' testImplementation 'org.robolectric:robolectric:4.12.2'
@ -365,6 +370,11 @@ dependencies {
androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion" androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion"
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion" debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
// Navigation
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
implementation "androidx.navigation:navigation-compose:$navVersion"
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha" implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha" implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"

View File

@ -3,5 +3,8 @@
<application> <application>
<uses-library android:name="android.test.runner" <uses-library android:name="android.test.runner"
android:required="false" /> android:required="false" />
<activity android:name="androidx.activity.ComponentActivity"/>
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,143 @@
package network.loki.messenger
import androidx.compose.ui.test.hasContentDescriptionExactly
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.CoreMatchers.*
import org.hamcrest.MatcherAssert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.groups.compose.CreateGroup
import org.thoughtcrime.securesms.groups.ViewState
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
@RunWith(AndroidJUnit4::class)
@SmallTest
class CreateGroupTests {
@get:Rule
val composeTest = createComposeRule()
@Test
fun testNavigateToCreateGroup() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val nameDesc = application.getString(R.string.AccessibilityId_closed_group_edit_group_name)
val buttonDesc = application.getString(R.string.AccessibilityId_create_closed_group_create_button)
var backPressed = false
var closePressed = false
composeTest.setContent {
PreviewTheme {
CreateGroup(
viewState = ViewState.DEFAULT,
onBack = { backPressed = true },
onClose = { closePressed = true },
onContactItemClicked = {},
updateState = {}
)
}
}
with(composeTest) {
onNode(hasContentDescriptionExactly(nameDesc)).performTextInput("Name")
onNode(hasContentDescriptionExactly(buttonDesc)).performClick()
}
assertThat(backPressed, equalTo(false))
assertThat(closePressed, equalTo(false))
}
@Test
fun testFailToCreate() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val nameDesc = application.getString(R.string.AccessibilityId_closed_group_edit_group_name)
val buttonDesc = application.getString(R.string.AccessibilityId_create_closed_group_create_button)
var backPressed = false
var closePressed = false
composeTest.setContent {
PreviewTheme {
CreateGroup(
viewState = ViewState.DEFAULT,
onBack = { backPressed = true },
onClose = { closePressed = true },
updateState = {},
onContactItemClicked = {}
)
}
}
with(composeTest) {
onNode(hasContentDescriptionExactly(nameDesc)).performTextInput("")
onNode(hasContentDescriptionExactly(buttonDesc)).performClick()
}
assertThat(backPressed, equalTo(false))
assertThat(closePressed, equalTo(false))
}
@Test
fun testBackButton() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val backDesc = application.getString(R.string.new_conversation_dialog_back_button_content_description)
var backPressed = false
composeTest.setContent {
PreviewTheme {
CreateGroup(
viewState = ViewState.DEFAULT,
onBack = { backPressed = true },
onClose = {},
onContactItemClicked = {},
updateState = {}
)
}
}
with (composeTest) {
onNode(hasContentDescriptionExactly(backDesc)).performClick()
}
assertThat(backPressed, equalTo(true))
}
@Test
fun testCloseButton() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val closeDesc = application.getString(R.string.new_conversation_dialog_close_button_content_description)
var closePressed = false
composeTest.setContent {
PreviewTheme {
CreateGroup(
viewState = ViewState.DEFAULT,
onBack = { },
onClose = { closePressed = true },
onContactItemClicked = {},
updateState = {}
)
}
}
with (composeTest) {
onNode(hasContentDescriptionExactly(closeDesc)).performClick()
}
assertThat(closePressed, equalTo(true))
}
}

View File

@ -0,0 +1,257 @@
package network.loki.messenger
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasContentDescriptionExactly
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.groups.compose.EditGroup
import org.thoughtcrime.securesms.groups.EditGroupViewState
import org.thoughtcrime.securesms.groups.MemberState
import org.thoughtcrime.securesms.groups.MemberViewModel
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
@RunWith(AndroidJUnit4::class)
@SmallTest
class EditGroupTests {
@get:Rule
val composeTest = createComposeRule()
val oneMember = MemberViewModel(
"Test User",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
MemberState.InviteSent,
false
)
val twoMember = MemberViewModel(
"Test User 2",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235",
MemberState.InviteFailed,
false
)
val threeMember = MemberViewModel(
"Test User 3",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236",
MemberState.Member,
false
)
val fourMember = MemberViewModel(
"Test User 4",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1237",
MemberState.Admin,
false
)
@Test
fun testDisplaysNameAndDesc() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val nameDesc = application.getString(R.string.AccessibilityId_group_name)
val descriptionDesc = application.getString(R.string.AccessibilityId_group_description)
with (composeTest) {
val state = EditGroupViewState(
"TestGroup",
"TestDesc",
emptyList(),
false
)
setContent {
PreviewTheme {
EditGroup(
onBackClick = {},
onAddMemberClick = {},
onResendInviteClick = {},
onPromoteClick = {},
onRemoveClick = {},
onEditName = {},
onMemberSelected = {},
viewState = state
)
}
}
onNode(hasContentDescriptionExactly(nameDesc)).assertTextEquals("TestGroup")
onNode(hasContentDescriptionExactly(descriptionDesc)).assertTextEquals("TestDesc")
}
}
@Test
fun testDisplaysReinviteProperly() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
var reinvited = false
with (composeTest) {
val state = EditGroupViewState(
"TestGroup",
"TestDesc",
listOf(
twoMember
),
// reinvite only shows for admin users
true
)
setContent {
PreviewTheme {
EditGroup(
onBackClick = {},
onAddMemberClick = {},
onResendInviteClick = { reinvited = true },
onPromoteClick = {},
onRemoveClick = {},
onEditName = {},
onMemberSelected = {},
viewState = state
)
}
}
onNodeWithContentDescription(reinviteDesc).assertIsDisplayed().performClick()
onNodeWithContentDescription(promoteDesc).assertDoesNotExist()
assertThat(reinvited, equalTo(true))
}
}
@Test
fun testDisplaysRegularMemberProperly() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
var promoted = false
with (composeTest) {
val state = EditGroupViewState(
"TestGroup",
"TestDesc",
listOf(
threeMember
),
// reinvite only shows for admin users
true
)
setContent {
PreviewTheme {
EditGroup(
onBackClick = {},
onAddMemberClick = {},
onResendInviteClick = {},
onPromoteClick = { promoted = true },
onRemoveClick = {},
onEditName = {},
onMemberSelected = {},
viewState = state
)
}
}
onNodeWithContentDescription(reinviteDesc).assertDoesNotExist()
onNodeWithContentDescription(promoteDesc).assertIsDisplayed().performClick()
assertThat(promoted, equalTo(true))
}
}
@Test
fun testDisplaysAdminProperly() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
with (composeTest) {
val state = EditGroupViewState(
"TestGroup",
"TestDesc",
listOf(
fourMember
),
// reinvite only shows for admin users
true
)
setContent {
PreviewTheme {
EditGroup(
onBackClick = {},
onAddMemberClick = {},
onResendInviteClick = {},
onPromoteClick = {},
onRemoveClick = {},
onEditName = {},
onMemberSelected = {},
viewState = state
)
}
}
onNodeWithContentDescription(reinviteDesc).assertDoesNotExist()
onNodeWithContentDescription(promoteDesc).assertDoesNotExist()
}
}
@Test
fun testDisplaysPendingInviteProperly() {
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
// Accessibility IDs
val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
val stateDesc = application.getString(R.string.AccessibilityId_member_state)
val memberDesc = application.getString(R.string.AccessibilityId_contact)
with (composeTest) {
val state = EditGroupViewState(
"TestGroup",
"TestDesc",
listOf(
oneMember
),
// reinvite only shows for admin users
true
)
setContent {
PreviewTheme {
EditGroup(
onBackClick = {},
onAddMemberClick = {},
onResendInviteClick = {},
onPromoteClick = {},
onRemoveClick = {},
onEditName = {},
onMemberSelected = {},
viewState = state
)
}
}
onNodeWithContentDescription(reinviteDesc).assertDoesNotExist()
onNodeWithContentDescription(promoteDesc).assertDoesNotExist()
onNodeWithContentDescription(stateDesc, useUnmergedTree = true).assertTextEquals("InviteSent")
onNodeWithContentDescription(memberDesc, useUnmergedTree = true).assertTextEquals("Test User")
}
}
}

View File

@ -1,12 +1,11 @@
package network.loki.messenger package network.loki.messenger
import android.Manifest
import android.app.Instrumentation import android.app.Instrumentation
import android.view.View import android.view.View
import android.content.ClipboardManager
import android.content.Context
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed
@ -16,14 +15,15 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.util.sendMessage
import network.loki.messenger.util.waitFor
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDevice
import com.adevinta.android.barista.interaction.PermissionGranter import com.adevinta.android.barista.interaction.PermissionGranter
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.not import org.hamcrest.Matchers.not
import org.junit.After import org.junit.After
@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.home.HomeActivity
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @SmallTest
class HomeActivityTests { class HomeActivityTests {
@get:Rule @get:Rule
@ -108,6 +108,7 @@ class HomeActivityTests {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
// new chat // new chat
Thread.sleep(500)
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard()) onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
onView(withId(R.id.copyButton)).perform(ViewActions.click()) onView(withId(R.id.copyButton)).perform(ViewActions.click())
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
@ -147,12 +148,14 @@ class HomeActivityTests {
setupLoggedInState() setupLoggedInState()
goToMyChat() goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(context, true) TextSecurePreferences.setLinkPreviewsEnabled(context, true)
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
sendMessage("howdy") sendMessage("howdy")
sendMessage("test") sendMessage("test")
// tests url rewriter doesn't crash // tests url rewriter doesn't crash
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest") sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
sendMessage("https://www.ámazon.com") sendMessage("https://www.ámazon.com")
} }
}
@Test @Test
fun testChat_displaysCorrectUrl() { fun testChat_displaysCorrectUrl() {
@ -161,7 +164,9 @@ class HomeActivityTests {
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
// given the link url text // given the link url text
val url = "https://www.ámazon.com" val url = "https://www.ámazon.com"
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
sendMessage(url, LinkPreview(url, "amazon", Optional.absent())) sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
}
// when the URL span is clicked // when the URL span is clicked
onView(withSubstring(url)).perform(ViewActions.click()) onView(withSubstring(url)).perform(ViewActions.click())
@ -175,21 +180,4 @@ class HomeActivityTests {
onView(withText(dialogPromptText)).check(matches(isDisplayed())) onView(withText(dialogPromptText)).check(matches(isDisplayed()))
}*/ }*/
/**
* Perform action of waiting for a specific time.
*/
fun waitFor(millis: Long): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View>? {
return isRoot()
}
override fun getDescription(): String = "Wait for $millis milliseconds."
override fun perform(uiController: UiController, view: View?) {
uiController.loopMainThreadForAtLeast(millis)
}
}
}
} }

View File

@ -11,6 +11,10 @@ import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.util.applySpiedStorage
import network.loki.messenger.util.maybeGetUserInfo
import network.loki.messenger.util.randomSeedBytes
import network.loki.messenger.util.randomSessionId
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.instanceOf import org.hamcrest.CoreMatchers.instanceOf
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
@ -31,32 +35,15 @@ import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@SmallTest @SmallTest
class LibSessionTests { class LibSessionTests {
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0 private var fakeHashI = 0
private val nextFakeHash: String private val nextFakeHash: String
get() = "fakehash${fakeHashI++}" get() = "fakehash${fakeHashI++}"
private fun maybeGetUserInfo(): Pair<ByteArray, String>? {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val prefs = appContext.prefs
val localUserPublicKey = prefs.getLocalNumber()
val secretKey = with(appContext) {
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
edKey.secretKey.asBytes
}
return if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}
private fun buildContactMessage(contactList: List<Contact>): ByteArray { private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!! val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.newInstance(key) val contacts = Contacts.newInstance(key)
@ -98,11 +85,10 @@ class LibSessionTests {
@Test @Test
fun migration_one_to_ones() { fun migration_one_to_ones() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage) val storage = applicationContext.applySpiedStorage()
app.storage = storageSpy
val newContactId = randomAccountId() val newContactId = randomSessionId()
val singleContact = Contact( val singleContact = Contact(
id = newContactId, id = newContactId,
approved = true, approved = true,
@ -111,10 +97,10 @@ class LibSessionTests {
val newContactMerge = buildContactMessage(listOf(singleContact)) val newContactMerge = buildContactMessage(listOf(singleContact))
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
fakePollNewConfig(contacts, newContactMerge) fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat { verify(storage).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1 first().let { it.id == newContactId && it.approved } && size == 1
}, any()) }, any())
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
} }
@Test @Test
@ -123,7 +109,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage) val storageSpy = spy(app.storage)
app.storage = storageSpy app.storage = storageSpy
val randomRecipient = randomAccountId() val randomRecipient = randomSessionId()
val newContact = Contact( val newContact = Contact(
id = randomRecipient, id = randomRecipient,
approved = true, approved = true,
@ -158,7 +144,7 @@ class LibSessionTests {
app.storage = storageSpy app.storage = storageSpy
// Initial state // Initial state
val randomRecipient = randomAccountId() val randomRecipient = randomSessionId()
val currentContact = Contact( val currentContact = Contact(
id = randomRecipient, id = randomRecipient,
approved = true, approved = true,

View File

@ -0,0 +1,82 @@
package network.loki.messenger.util
import android.Manifest
import android.view.View
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.platform.app.InstrumentationRegistry
import com.adevinta.android.barista.interaction.PermissionGranter
import network.loki.messenger.R
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
import org.thoughtcrime.securesms.mms.GlideApp
fun setupLoggedInState(hasViewedSeed: Boolean = false) {
// landing activity
onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
// session ID - register activity
onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
// display name selection
onView(ViewMatchers.withId(R.id.displayNameEditText))
.perform(ViewActions.typeText("test-user123"))
onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
// PN select
if (hasViewedSeed) {
// has viewed seed is set to false after register activity
TextSecurePreferences.setHasViewedSeed(
InstrumentationRegistry.getInstrumentation().targetContext,
true
)
}
onView(ViewMatchers.withId(R.id.backgroundPollingOptionView))
.perform(ViewActions.click())
onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
// allow notification permission
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
}
fun ConversationActivityV2.sendMessage(messageToSend: String, linkPreview: LinkPreview? = null) {
// assume in chat activity
onView(
Matchers.allOf(
ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.inputBar)),
ViewMatchers.withId(R.id.inputBarEditText)
)
).perform(ViewActions.replaceText(messageToSend))
if (linkPreview != null) {
val glide = GlideApp.with(this)
this.findViewById<InputBar>(R.id.inputBar).updateLinkPreviewDraft(glide, linkPreview)
}
onView(
Matchers.allOf(
ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.inputBar)),
InputBarButtonDrawableMatcher.inputButtonWithDrawable(R.drawable.ic_arrow_up)
)
).perform(ViewActions.click())
// TODO: text can flaky on cursor reload, figure out a better way to wait for the UI to settle with new data
onView(ViewMatchers.isRoot()).perform(waitFor(500))
}
/**
* Perform action of waiting for a specific time.
*/
fun waitFor(millis: Long): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View>? {
return ViewMatchers.isRoot()
}
override fun getDescription(): String = "Wait for $millis milliseconds."
override fun perform(uiController: UiController, view: View?) {
uiController.loopMainThreadForAtLeast(millis)
}
}
}

View File

@ -0,0 +1,31 @@
package network.loki.messenger.util
import androidx.test.platform.app.InstrumentationRegistry
import org.mockito.kotlin.spy
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.Storage
import kotlin.random.Random
fun maybeGetUserInfo(): Pair<ByteArray, String>? {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val prefs = appContext.prefs
val localUserPublicKey = prefs.getLocalNumber()
val secretKey = with(appContext) {
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
edKey.secretKey.asBytes
}
return if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}
fun ApplicationContext.applySpiedStorage(): Storage {
val storageSpy = spy(storage)!!
storage = storageSpy
return storageSpy
}
fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey

View File

@ -154,7 +154,13 @@
android:label="@string/conversationsBlockedContacts" android:label="@string/conversationsBlockedContacts"
/> />
<activity <activity
android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity" android:name="org.thoughtcrime.securesms.groups.EditLegacyGroupActivity"
android:label="@string/groupEdit"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.groups.EditGroupActivity"
android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:label="@string/groupEdit" android:label="@string/groupEdit"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity <activity
@ -237,7 +243,13 @@
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" /> android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity> </activity>
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.NoActionBar"/>
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationNotificationSettingsActivity"
android:label="@string/sessionNotifications"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight"/>
<activity <activity
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity" android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"

View File

@ -25,8 +25,10 @@ import android.content.Intent;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Handler; import android.os.Handler;
import android.os.HandlerThread; import android.os.HandlerThread;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat; import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat; import androidx.core.graphics.drawable.IconCompat;
@ -34,12 +36,16 @@ import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner;
import com.squareup.phrase.Phrase;
import org.conscrypt.Conscrypt; import org.conscrypt.Conscrypt;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.avatars.AvatarHelper; import org.session.libsession.avatars.AvatarHelper;
import org.session.libsession.database.MessageDataProvider; import org.session.libsession.database.MessageDataProvider;
import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.notifications.TokenFetcher;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
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.pollers.Poller; import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule; import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
@ -49,6 +55,7 @@ import org.session.libsession.utilities.Environment;
import org.session.libsession.utilities.ProfilePictureUtilities; import org.session.libsession.utilities.ProfilePictureUtilities;
import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.SSKEnvironment;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.Toaster;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.WindowDebouncer;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper; import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
@ -59,7 +66,6 @@ import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.ThreadUtils; import org.session.libsignal.utilities.ThreadUtils;
import org.signal.aesgcmprovider.AesGcmProvider; import org.signal.aesgcmprovider.AesGcmProvider;
import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.LastSentTimestampCache; import org.thoughtcrime.securesms.database.LastSentTimestampCache;
import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.LokiAPIDatabase;
@ -71,6 +77,7 @@ import org.thoughtcrime.securesms.dependencies.AppComponent;
import org.thoughtcrime.securesms.dependencies.ConfigFactory; import org.thoughtcrime.securesms.dependencies.ConfigFactory;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.dependencies.PollerFactory;
import org.thoughtcrime.securesms.emoji.EmojiSource; import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.groups.OpenGroupManager; import org.thoughtcrime.securesms.groups.OpenGroupManager;
import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.home.HomeActivity;
@ -80,9 +87,9 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger; import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker; import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
import org.thoughtcrime.securesms.notifications.PushRegistrationHandler;
import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier; import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
import org.thoughtcrime.securesms.notifications.PushRegistry;
import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.KeyCachingService;
@ -103,6 +110,7 @@ import java.security.Security;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Timer; import java.util.Timer;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@ -113,7 +121,7 @@ import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit; import kotlin.Unit;
import network.loki.messenger.BuildConfig; import network.loki.messenger.BuildConfig;
import network.loki.messenger.R; import network.loki.messenger.R;
import network.loki.messenger.libsession_util.ConfigBase; import network.loki.messenger.libsession_util.Config;
import network.loki.messenger.libsession_util.UserProfile; import network.loki.messenger.libsession_util.UserProfile;
/** /**
@ -125,7 +133,7 @@ import network.loki.messenger.libsession_util.UserProfile;
* @author Moxie Marlinspike * @author Moxie Marlinspike
*/ */
@HiltAndroidApp @HiltAndroidApp
public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener { public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener, Toaster {
public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
@ -149,10 +157,13 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject Device device; @Inject Device device;
@Inject MessageDataProvider messageDataProvider; @Inject MessageDataProvider messageDataProvider;
@Inject TextSecurePreferences textSecurePreferences; @Inject TextSecurePreferences textSecurePreferences;
@Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory; @Inject ConfigFactory configFactory;
@Inject PollerFactory pollerFactory;
@Inject LastSentTimestampCache lastSentTimestampCache; @Inject LastSentTimestampCache lastSentTimestampCache;
@Inject VersionDataFetcher versionDataFetcher; @Inject VersionDataFetcher versionDataFetcher;
@Inject
PushRegistrationHandler pushRegistrationHandler;
@Inject TokenFetcher tokenFetcher;
CallMessageProcessor callMessageProcessor; CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@ -201,7 +212,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
@Override @Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) { public void notifyUpdates(@NotNull Config forConfigObject, long messageTimestamp) {
// forward to the config factory / storage ig // forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) { if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true); textSecurePreferences.setConfigurationMessageSynced(true);
@ -209,6 +220,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
storage.notifyConfigUpdates(forConfigObject, messageTimestamp); storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
} }
@Override
public void toast(@StringRes int stringRes, int toastLength, @NonNull Map<String, String> parameters) {
Phrase builder = Phrase.from(this, stringRes);
for (Map.Entry<String,String> entry : parameters.entrySet()) {
builder.put(entry.getKey(), entry.getValue());
}
Toast.makeText(getApplicationContext(), builder.format(), toastLength).show();
}
@Override @Override
public void onCreate() { public void onCreate() {
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX); TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
@ -222,9 +242,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
storage, storage,
device, device,
messageDataProvider, messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory, configFactory,
lastSentTimestampCache lastSentTimestampCache,
this,
tokenFetcher
); );
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()"); Log.i(TAG, "onCreate()");
@ -256,6 +277,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create(); NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
pushRegistrationHandler.run();
// add our shortcut debug menu if we are not in a release build // add our shortcut debug menu if we are not in a release build
if (BuildConfig.BUILD_TYPE != "release") { if (BuildConfig.BUILD_TYPE != "release") {
// add the config settings shortcut // add the config settings shortcut
@ -308,7 +331,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) { if (poller != null) {
poller.stopIfNeeded(); poller.stopIfNeeded();
} }
ClosedGroupPollerV2.getShared().stopAll(); pollerFactory.stopAll();
LegacyClosedGroupPollerV2.getShared().stopAll();
versionDataFetcher.stopTimedVersionCheck(); versionDataFetcher.stopTimedVersionCheck();
} }
@ -316,6 +340,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public void onTerminate() { public void onTerminate() {
stopKovenant(); // Loki stopKovenant(); // Loki
OpenGroupManager.INSTANCE.stopPolling(); OpenGroupManager.INSTANCE.stopPolling();
pollerFactory.stopAll();
versionDataFetcher.stopTimedVersionCheck(); versionDataFetcher.stopTimedVersionCheck();
super.onTerminate(); super.onTerminate();
} }
@ -438,7 +463,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
poller.setUserPublicKey(userPublicKey); poller.setUserPublicKey(userPublicKey);
return; return;
} }
poller = new Poller(configFactory, new Timer()); poller = new Poller(configFactory);
} }
public void startPollingIfNeeded() { public void startPollingIfNeeded() {
@ -446,7 +471,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) { if (poller != null) {
poller.startIfNeeded(); poller.startIfNeeded();
} }
ClosedGroupPollerV2.getShared().start(); pollerFactory.startAll();
LegacyClosedGroupPollerV2.getShared().start();
} }
public void retrieveUserProfile() { public void retrieveUserProfile() {

View File

@ -405,6 +405,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
@SuppressWarnings("CodeBlock2Expr") @SuppressWarnings("CodeBlock2Expr")
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private void saveToDisk() { private void saveToDisk() {
Log.w("ACL", "Asked to save to disk!");
MediaItem mediaItem = getCurrentMediaItem(); MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null) return; if (mediaItem == null) return;

View File

@ -142,11 +142,11 @@ class SessionDialogBuilder(val context: Context) {
fun dangerButton( fun dangerButton(
@StringRes text: Int, @StringRes text: Int,
@StringRes contentDescription: Int = text, @StringRes contentDescriptionRes: Int = text,
listener: () -> Unit = {} listener: () -> Unit = {}
) = button( ) = button(
text, text,
contentDescription, contentDescriptionRes,
R.style.Widget_Session_Button_Dialog_DangerText, R.style.Widget_Session_Button_Dialog_DangerText,
) { listener() } ) { listener() }

View File

@ -215,13 +215,29 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) } threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) }
} }
override fun updateMessageAsDeleted(messageId: Long, isSms: Boolean): Long {
val messagingDatabase: MessagingDatabase =
if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase()
val isOutgoing = messagingDatabase.isOutgoing(messageId)
messagingDatabase.markAsDeleted(messageId)
if (isOutgoing) {
messagingDatabase.deleteMessage(messageId)
}
return messageId
}
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = Address.fromSerialized(author) val address = Address.fromSerialized(author)
val message = database.getMessageFor(timestamp, address) ?: return null val message = database.getMessageFor(timestamp, address) ?: return null
updateMessageAsDeleted(message.id, !message.isMms)
val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase() val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase()
else DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).smsDatabase()
messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention) messagingDatabase.markAsDeleted(message.id)
if (message.isOutgoing) { if (message.isOutgoing) {
messagingDatabase.deleteMessage(message.id) messagingDatabase.deleteMessage(message.id)
} }

View File

@ -51,19 +51,19 @@ class ProfilePictureView @JvmOverloads constructor(
} }
fun update(recipient: Recipient) { fun update(recipient: Recipient) {
recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) } recipient.run { update(address, isLegacyClosedGroupRecipient, isOpenGroupInboxRecipient) }
} }
fun update( fun update(
address: Address, address: Address,
isClosedGroupRecipient: Boolean = false, isLegacyClosedGroupRecipient: Boolean = false,
isOpenGroupInboxRecipient: Boolean = false isOpenGroupInboxRecipient: Boolean = false
) { ) {
fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName() fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName()
?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR) ?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR)
?: publicKey ?: publicKey
if (isClosedGroupRecipient) { if (isLegacyClosedGroupRecipient) {
val members = DatabaseComponent.get(context).groupDatabase() val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(address.toGroupString(), true) .getGroupMemberAddresses(address.toGroupString(), true)
.sorted() .sorted()

View File

@ -8,7 +8,6 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View File

@ -52,7 +52,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
private fun getClosedGroups(contacts: List<Recipient>): List<ContactSelectionListItem> { private fun getClosedGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
return getItems(contacts, context.getString(R.string.conversationsGroups)) { return getItems(contacts, context.getString(R.string.conversationsGroups)) {
it.address.isClosedGroup it.address.isLegacyClosedGroup || it.address.isClosedGroupV2
} }
} }

View File

@ -13,7 +13,6 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationActionBarBinding import network.loki.messenger.databinding.ViewConversationActionBarBinding
@ -31,6 +30,8 @@ import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.ui.getSubbedString
import org.thoughtcrime.securesms.database.Storage
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ConversationActionBarView @JvmOverloads constructor( class ConversationActionBarView @JvmOverloads constructor(
@ -42,6 +43,7 @@ class ConversationActionBarView @JvmOverloads constructor(
@Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var groupDb: GroupDatabase @Inject lateinit var groupDb: GroupDatabase
@Inject lateinit var storage: Storage
var delegate: ConversationActionBarDelegate? = null var delegate: ConversationActionBarDelegate? = null
@ -51,6 +53,9 @@ class ConversationActionBarView @JvmOverloads constructor(
} }
} }
val profilePictureView
get() = binding.profilePictureView
init { init {
var previousState: Int var previousState: Int
var currentState = 0 var currentState = 0
@ -80,7 +85,7 @@ class ConversationActionBarView @JvmOverloads constructor(
) { ) {
this.delegate = delegate this.delegate = delegate
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size if (recipient.isClosedGroupV2Recipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
).let { LayoutParams(it, it) } ).let { LayoutParams(it, it) }
update(recipient, openGroup, config) update(recipient, openGroup, config)
} }
@ -141,7 +146,11 @@ class ConversationActionBarView @JvmOverloads constructor(
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
resources.getQuantityString(R.plurals.membersActive, userCount, userCount) resources.getQuantityString(R.plurals.membersActive, userCount, userCount)
} else { } else {
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size val userCount = if (recipient.isClosedGroupV2Recipient) {
storage.getMembers(recipient.address.serialize()).size
} else { // legacy closed groups
groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
}
resources.getQuantityString(R.plurals.members, userCount, userCount) resources.getQuantityString(R.plurals.members, userCount, userCount)
} }
settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT) settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT)

View File

@ -5,6 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
@ -43,8 +44,12 @@ class DisappearingMessages @Inject constructor(
messageExpirationManager.insertExpirationTimerMessage(message) messageExpirationManager.insertExpirationTimerMessage(message)
MessageSender.send(message, address) MessageSender.send(message, address)
if (address.isClosedGroupV2) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.from(address))
} else {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} }
}
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
title(R.string.disappearingMessagesFollowSetting) title(R.string.disappearingMessagesFollowSetting)
@ -58,9 +63,9 @@ class DisappearingMessages @Inject constructor(
dangerButton( dangerButton(
text = if (message.expiresIn == 0L) R.string.confirm else R.string.set, text = if (message.expiresIn == 0L) R.string.confirm else R.string.set,
contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton contentDescriptionRes = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton
) { ) {
set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient) set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupV2Recipient)
} }
cancelButton() cancelButton()
} }

View File

@ -59,16 +59,28 @@ class DisappearingMessagesViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
val recipient = threadDb.getRecipientForThreadId(threadId) val recipient = threadDb.getRecipientForThreadId(threadId) ?: return@launch
val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient }
?.run { groupDb.getGroup(address.toGroupString()).orNull() } ?.run { groupDb.getGroup(address.toGroupString()).orNull() }
val isAdmin = when {
recipient.isClosedGroupV2Recipient -> {
// Handle the new closed group functionality
storage.getMembers(recipient.address.serialize()).any { it.sessionId == textSecurePreferences.getLocalNumber() && it.admin }
}
recipient.isLegacyClosedGroupRecipient -> {
// Handle as legacy group
groupRecord?.admins?.any{ it.serialize() == textSecurePreferences.getLocalNumber() } == true
}
else -> !recipient.isGroupRecipient
}
_state.update { _state.update {
it.copy( it.copy(
address = recipient?.address, address = recipient.address,
isGroup = groupRecord != null, isGroup = recipient.isGroupRecipient,
isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(), isNoteToSelf = recipient.address.serialize() == textSecurePreferences.getLocalNumber(),
isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() }, isSelfAdmin = isAdmin,
expiryMode = expiryMode, expiryMode = expiryMode,
persistedMode = expiryMode persistedMode = expiryMode
) )

View File

@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.conversation.settings
import android.os.Bundle
import android.view.View
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.ActivityConversationNotificationSettingsBinding
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import javax.inject.Inject
@AndroidEntryPoint
class ConversationNotificationSettingsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener {
lateinit var binding: ActivityConversationNotificationSettingsBinding
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var recipientDb: RecipientDatabase
val recipient by lazy {
if (threadId == -1L) null
else threadDb.getRecipientForThreadId(threadId)
}
var threadId: Long = -1
override fun onClick(v: View?) {
val recipient = recipient ?: return
if (v === binding.notifyAll) {
// set notify type
recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_ALL)
} else if (v === binding.notifyMentions) {
recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_MENTIONS)
} else if (v === binding.notifyMute) {
recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_NONE)
}
updateValues()
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
binding = ActivityConversationNotificationSettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
threadId = intent.getLongExtra(ConversationActivityV2.THREAD_ID, -1L)
if (threadId == -1L) finish()
updateValues()
with (binding) {
notifyAll.setOnClickListener(this@ConversationNotificationSettingsActivity)
notifyMentions.setOnClickListener(this@ConversationNotificationSettingsActivity)
notifyMute.setOnClickListener(this@ConversationNotificationSettingsActivity)
}
}
private fun updateValues() {
val notifyType = recipient?.notifyType ?: return
binding.notifyAllButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_ALL
binding.notifyMentionsButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS
binding.notifyMuteButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_NONE
}
}

View File

@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.conversation.settings
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
class ConversationNotificationSettingsActivityContract: ActivityResultContract<Long, Unit>() {
override fun createIntent(context: Context, input: Long): Intent =
Intent(context, ConversationNotificationSettingsActivity::class.java).apply {
putExtra(ConversationActivityV2.THREAD_ID, input)
}
override fun parseResult(resultCode: Int, intent: Intent?) { /* do nothing */ }
}

View File

@ -0,0 +1,269 @@
package org.thoughtcrime.securesms.conversation.settings
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationSettingsBinding
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.groups.EditGroupActivity
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
import org.thoughtcrime.securesms.media.MediaOverviewActivity
import org.thoughtcrime.securesms.showSessionDialog
import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint
class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener {
companion object {
// used to trigger displaying conversation search in calling parent activity
const val RESULT_SEARCH = 22
}
lateinit var binding: ActivityConversationSettingsBinding
private val groupOptions: List<View>
get() = with(binding) {
listOf(
groupMembers,
groupMembersDivider.root,
editGroup,
editGroupDivider.root,
leaveGroup,
leaveGroupDivider.root
)
}
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var groupDb: GroupDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var viewModelFactory: ConversationSettingsViewModel.AssistedFactory
val viewModel: ConversationSettingsViewModel by viewModels {
val threadId = intent.getLongExtra(ConversationActivityV2.THREAD_ID, -1L)
if (threadId == -1L) {
finish()
}
viewModelFactory.create(threadId)
}
private val notificationActivityCallback = registerForActivityResult(ConversationNotificationSettingsActivityContract()) {
updateRecipientDisplay()
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
binding = ActivityConversationSettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
updateRecipientDisplay()
binding.searchConversation.setOnClickListener(this)
binding.clearMessages.setOnClickListener(this)
binding.allMedia.setOnClickListener(this)
binding.pinConversation.setOnClickListener(this)
binding.notificationSettings.setOnClickListener(this)
binding.editGroup.setOnClickListener(this)
binding.leaveGroup.setOnClickListener(this)
binding.back.setOnClickListener(this)
binding.autoDownloadMediaSwitch.setOnCheckedChangeListener { _, isChecked ->
viewModel.setAutoDownloadAttachments(isChecked)
updateRecipientDisplay()
}
}
private fun updateRecipientDisplay() {
val recipient = viewModel.recipient ?: return
// Setup profile image
binding.profilePictureView.root.update(recipient)
// Setup name
binding.conversationName.text = when {
recipient.isLocalNumber -> getString(R.string.noteToSelf)
else -> recipient.toShortString()
}
// Setup group description (if group)
binding.conversationSubtitle.isVisible = recipient.isClosedGroupV2Recipient.apply {
binding.conversationSubtitle.text = viewModel.closedGroupInfo()?.description
}
// Toggle group-specific settings
val areGroupOptionsVisible = recipient.isClosedGroupV2Recipient || recipient.isLegacyClosedGroupRecipient
groupOptions.forEach { v ->
v.isVisible = areGroupOptionsVisible
}
// Group admin settings
val isUserGroupAdmin = areGroupOptionsVisible && viewModel.isUserGroupAdmin()
with (binding) {
groupMembersDivider.root.isVisible = areGroupOptionsVisible && !isUserGroupAdmin
groupMembers.isVisible = !isUserGroupAdmin
adminControlsGroup.isVisible = isUserGroupAdmin
deleteGroup.isVisible = isUserGroupAdmin
clearMessages.isVisible = isUserGroupAdmin
clearMessagesDivider.root.isVisible = isUserGroupAdmin
leaveGroupDivider.root.isVisible = isUserGroupAdmin
}
// Set pinned state
binding.pinConversation.setText(
if (viewModel.isPinned()) R.string.pinUnpinConversation
else R.string.pinConversation
)
// Set auto-download state
val trusted = viewModel.autoDownloadAttachments()
binding.autoDownloadMediaSwitch.isChecked = trusted
// Set notification type
val notifyTypes = resources.getStringArray(R.array.notify_types)
val summary = notifyTypes.getOrNull(recipient.notifyType)
binding.notificationsValue.text = summary
}
override fun onClick(v: View?) {
val threadRecipient = viewModel.recipient ?: return
when {
v === binding.searchConversation -> {
setResult(RESULT_SEARCH)
finish()
}
v === binding.allMedia -> {
startActivity(MediaOverviewActivity.createIntent(this, threadRecipient.address))
}
v === binding.pinConversation -> {
viewModel.togglePin().invokeOnCompletion { e ->
if (e != null) {
// something happened
Log.e("ConversationSettings", "Failed to toggle pin on thread", e)
} else {
updateRecipientDisplay()
}
}
}
v === binding.notificationSettings -> {
notificationActivityCallback.launch(viewModel.threadId)
}
v === binding.back -> onBackPressed()
v === binding.clearMessages -> {
showSessionDialog {
title(R.string.clearMessages)
text(Phrase.from(this@ConversationSettingsActivity, R.string.clearMessagesChatDescription)
.put(NAME_KEY, threadRecipient.name)
.format())
dangerButton(
R.string.clear,
R.string.clear) {
viewModel.clearMessages(false)
}
cancelButton()
}
}
v === binding.leaveGroup -> {
if (threadRecipient.isLegacyClosedGroupRecipient) {
// Send a leave group message if this is an active closed group
val groupString = threadRecipient.address.toGroupString()
val ourId = TextSecurePreferences.getLocalNumber(this)!!
if (groupDb.isActive(groupString)) {
showSessionDialog {
title(R.string.groupLeave)
val name = viewModel.recipient!!.name!!
val textWithArgs = if (groupDb.getGroup(groupString).get().admins.map(Address::serialize).contains(ourId)) {
Phrase.from(context, R.string.groupLeaveDescriptionAdmin)
.put(GROUP_NAME_KEY, name)
.format()
} else {
Phrase.from(context, R.string.groupLeaveDescription)
.put(GROUP_NAME_KEY, name)
.format()
}
text(textWithArgs)
dangerButton(
R.string.groupLeave,
R.string.groupLeave
) {
lifecycleScope.launch {
GroupUtil.doubleDecodeGroupID(threadRecipient.address.toString())
.toHexString()
.let { MessageSender.explicitLeave(it, true, deleteThread = true) }
finish()
}
}
cancelButton()
}
try {
} catch (e: IOException) {
Log.e("Loki", e)
}
}
} else if (threadRecipient.isClosedGroupV2Recipient) {
val groupInfo = viewModel.closedGroupInfo()
showSessionDialog {
title(R.string.groupLeave)
val name = viewModel.recipient!!.name!!
val textWithArgs = if (groupInfo?.isUserAdmin == true) {
Phrase.from(context, R.string.groupLeaveDescription)
.put(GROUP_NAME_KEY, name)
.format()
} else {
Phrase.from(context, R.string.groupLeaveDescription)
.put(GROUP_NAME_KEY, name)
.format()
}
text(textWithArgs)
dangerButton(
R.string.groupLeave,
R.string.groupLeave
) {
lifecycleScope.launch {
viewModel.leaveGroup()
finish()
}
}
cancelButton()
}
}
}
v === binding.editGroup -> {
val recipient = viewModel.recipient ?: return
val intent = when {
recipient.isLegacyClosedGroupRecipient -> Intent(this, EditLegacyGroupActivity::class.java).apply {
val groupID: String = recipient.address.toGroupString()
putExtra(EditLegacyGroupActivity.groupIDKey, groupID)
}
recipient.isClosedGroupV2Recipient -> EditGroupActivity.createIntent(
context = this,
groupSessionId = recipient.address.serialize()
)
else -> return
}
startActivity(intent)
}
}
}
}

View File

@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.conversation.settings
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
sealed class ConversationSettingsActivityResult {
object Finished: ConversationSettingsActivityResult()
object SearchConversation: ConversationSettingsActivityResult()
}
class ConversationSettingsActivityContract: ActivityResultContract<Long, ConversationSettingsActivityResult>() {
override fun createIntent(context: Context, input: Long) = Intent(context, ConversationSettingsActivity::class.java).apply {
putExtra(ConversationActivityV2.THREAD_ID, input ?: -1L)
}
override fun parseResult(resultCode: Int, intent: Intent?): ConversationSettingsActivityResult =
when (resultCode) {
ConversationSettingsActivity.RESULT_SEARCH -> ConversationSettingsActivityResult.SearchConversation
else -> ConversationSettingsActivityResult.Finished
}
}

View File

@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.conversation.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.LibSessionGroupLeavingJob
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.AccountId
class ConversationSettingsViewModel(
val threadId: Long,
private val storage: StorageProtocol,
private val prefs: TextSecurePreferences
): ViewModel() {
val recipient get() = storage.getRecipientForThread(threadId)
fun isPinned() = storage.isPinned(threadId)
fun togglePin() = viewModelScope.launch {
val isPinned = storage.isPinned(threadId)
storage.setPinned(threadId, !isPinned)
}
fun autoDownloadAttachments() = recipient?.let { recipient -> storage.shouldAutoDownloadAttachments(recipient) } ?: false
fun setAutoDownloadAttachments(shouldDownload: Boolean) {
recipient?.let { recipient -> storage.setAutoDownloadAttachments(recipient, shouldDownload) }
}
fun isUserGroupAdmin(): Boolean = recipient?.let { recipient ->
when {
recipient.isLegacyClosedGroupRecipient -> {
val localUserAddress = prefs.getLocalNumber() ?: return@let false
val group = storage.getGroup(recipient.address.toGroupString())
group?.admins?.contains(Address.fromSerialized(localUserAddress)) ?: false // this will have to be replaced for new closed groups
}
recipient.isClosedGroupV2Recipient -> {
val group = storage.getLibSessionClosedGroup(recipient.address.serialize()) ?: return@let false
group.adminKey != null
}
else -> false
}
} ?: false
fun clearMessages(forAll: Boolean) {
if (forAll && !isUserGroupAdmin()) return
if (!forAll) {
viewModelScope.launch {
storage.clearMessages(threadId)
}
} else {
// do a send message here and on success do a clear messages
viewModelScope.launch {
storage.clearMessages(threadId)
}
}
}
fun closedGroupInfo(): GroupDisplayInfo? = recipient
?.address
?.takeIf { it.isClosedGroupV2 }
?.serialize()
?.let(storage::getClosedGroupDisplayInfo)
// Assume that user has verified they don't want to add a new admin etc
suspend fun leaveGroup() {
val recipient = recipient ?: return
return withContext(Dispatchers.IO) {
val groupLeave = LibSessionGroupLeavingJob(
AccountId(recipient.address.serialize()),
true
)
JobQueue.shared.add(groupLeave)
}
}
// DI-related
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long): Factory
}
class Factory @AssistedInject constructor(
@Assisted private val threadId: Long,
private val storage: StorageProtocol,
private val prefs: TextSecurePreferences
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationSettingsViewModel(threadId, storage, prefs) as T
}
}
}

View File

@ -66,7 +66,7 @@ class StartConversationFragment : BottomSheetDialogFragment(), StartConversation
} }
override fun onCreateGroupSelected() { override fun onCreateGroupSelected() {
replaceFragment(CreateGroupFragment().also { it.delegate = this }) replaceFragment(CreateGroupFragment())
} }
override fun onJoinCommunitySelected() { override fun onJoinCommunitySelected() {

View File

@ -23,6 +23,7 @@ import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.BackAppBar
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
import org.thoughtcrime.securesms.ui.components.border import org.thoughtcrime.securesms.ui.components.border

View File

@ -37,6 +37,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -142,8 +144,10 @@ private fun EnterAccountId(
SessionOutlinedTextField( SessionOutlinedTextField(
text = state.newMessageIdOrOns, text = state.newMessageIdOrOns,
modifier = Modifier modifier = Modifier
.padding(horizontal = LocalDimensions.current.spacing), .padding(horizontal = LocalDimensions.current.spacing)
contentDescription = "Session id input box", .semantics {
contentDescription = "Session id input box"
},
placeholder = stringResource(R.string.accountIdOrOnsEnter), placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange, onChange = callbacks::onChange,
onContinue = callbacks::onContinue, onContinue = callbacks::onContinue,

View File

@ -59,14 +59,23 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.applyExpiryMode
@ -80,7 +89,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
@ -100,6 +108,7 @@ import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.ListenableFuture
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
@ -111,6 +120,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityContract
import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityResult
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
@ -147,7 +158,6 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
@ -155,6 +165,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.getSearchName
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
@ -175,7 +186,6 @@ import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.NetworkUtils
@ -211,7 +221,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
ConversationMenuHelper.ConversationMenuListener { ConversationMenuHelper.ConversationMenuListener, View.OnClickListener {
private lateinit var binding: ActivityConversationV2Binding private lateinit var binding: ActivityConversationV2Binding
@ -224,7 +234,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var smsDb: SmsDatabase @Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase
@Inject lateinit var storage: Storage @Inject lateinit var storage: StorageProtocol
@Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
@ -236,6 +246,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private val conversationSettingsCallback = registerForActivityResult(ConversationSettingsActivityContract()) { result ->
if (result is ConversationSettingsActivityResult.SearchConversation) {
// open search
binding?.toolbar?.menu?.findItem(R.id.menu_search)?.expandActionView()
}
}
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val linkPreviewViewModel: LinkPreviewViewModel by lazy { private val linkPreviewViewModel: LinkPreviewViewModel by lazy {
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
@ -269,7 +286,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private val viewModel: ConversationViewModel by viewModels { private val viewModel: ConversationViewModel by viewModels {
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair())
} }
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var unreadCount = Int.MAX_VALUE private var unreadCount = Int.MAX_VALUE
@ -404,6 +421,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_GIF = 10 const val PICK_GIF = 10
const val PICK_FROM_LIBRARY = 12 const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124 const val INVITE_CONTACTS = 124
const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result
} }
// endregion // endregion
@ -478,11 +496,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updatePlaceholder() updatePlaceholder()
setUpBlockedBanner() setUpBlockedBanner()
binding.searchBottomBar.setEventListener(this) binding.searchBottomBar.setEventListener(this)
binding.toolbarContent.profilePictureView.setOnClickListener(this)
updateSendAfterApprovalText() updateSendAfterApprovalText()
setUpMessageRequestsBar() setUpMessageRequests()
// Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
// keyboard visible and have no need to immediately display it.
val weakActivity = WeakReference(this) val weakActivity = WeakReference(this)
@ -506,6 +522,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpSearchResultObserver() setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded() scrollToFirstUnreadMessageIfNeeded()
setUpOutdatedClientBanner() setUpOutdatedClientBanner()
setUpLegacyGroupBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding.conversationRecyclerView.scrollToPosition(targetPosition) binding.conversationRecyclerView.scrollToPosition(targetPosition)
@ -574,6 +591,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
override fun finish() {
super.finish()
}
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1) ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1)
@ -809,13 +830,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
legacyRecipient != null legacyRecipient != null
binding.outdatedBanner.isVisible = shouldShowLegacy binding.outdatedDisappearingBanner.isVisible = shouldShowLegacy
if (shouldShowLegacy) { if (shouldShowLegacy) {
val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy) val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy)
.put(NAME_KEY, legacyRecipient!!.name) .put(NAME_KEY, legacyRecipient!!.name)
.format() .format()
binding?.outdatedBannerTextView?.text = txt binding.outdatedBannerTextView.text = txt
}
}
private fun setUpLegacyGroupBanner() {
val shouldDisplayBanner = viewModel.recipient?.isLegacyClosedGroupRecipient ?: return
with(binding) {
outdatedGroupBanner.isVisible = shouldDisplayBanner
outdatedGroupBanner.setOnClickListener {
showSessionDialog {
title(R.string.urlOpenBrowser)
text(R.string.urlOpenDescription)
cancelButton()
dangerButton(R.string.open) {
// open the URL (tbc)
}
}
}
} }
} }
@ -840,21 +879,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun setUpUiStateObserver() { private fun setUpUiStateObserver() {
lifecycleScope.launchWhenStarted { // Observe toast messages
viewModel.uiState.collect { uiState -> lifecycleScope.launch {
uiState.uiMessages.firstOrNull()?.let { repeatOnLifecycle(Lifecycle.State.STARTED) {
Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show() viewModel.uiState
viewModel.messageShown(it.id) .mapNotNull { it.uiMessages.firstOrNull() }
.distinctUntilChanged()
.collect { msg ->
Toast.makeText(this@ConversationActivityV2, msg.message, Toast.LENGTH_LONG).show()
viewModel.messageShown(msg.id)
} }
if (uiState.isMessageRequestAccepted == true) {
binding.messageRequestBar.visibility = View.GONE
} }
if (!uiState.conversationExists && !isFinishing) { }
// Conversation should be deleted now, just go back
// When we see "shouldExit", we finish the activity once for all.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Wait for `shouldExit == true` then finish the activity
viewModel.uiState
.filter { it.shouldExit }
.first()
if (!isFinishing) {
finish() finish()
} }
} }
} }
// Observe the rest misc "simple" state change. They are bundled in one big
// state observing as these changes are relatively cheap to perform even redundantly.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
binding?.inputBar?.run {
isVisible = state.showInput
showMediaControls = state.enableInputMediaControls
}
}
}
}
} }
private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int { private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int {
@ -914,11 +977,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (threadRecipient.isContactRecipient) { if (threadRecipient.isContactRecipient) {
binding.blockedBanner.isVisible = threadRecipient.isBlocked binding.blockedBanner.isVisible = threadRecipient.isBlocked
} }
setUpMessageRequestsBar()
invalidateOptionsMenu() invalidateOptionsMenu()
updateSendAfterApprovalText() updateSendAfterApprovalText()
showOrHideInputIfNeeded()
maybeUpdateToolbar(threadRecipient) maybeUpdateToolbar(threadRecipient)
} }
} }
@ -931,48 +991,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText
} }
private fun showOrHideInputIfNeeded() { private fun setUpMessageRequests() {
binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } binding.acceptMessageRequestButton.setOnClickListener {
?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true } viewModel.acceptMessageRequest()
?: true
} }
private fun setUpMessageRequestsBar() {
binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread()
binding.messageRequestBar.isVisible = isIncomingMessageRequestThread()
binding.acceptMessageRequestButton.setOnClickListener {
acceptMessageRequest()
}
binding.messageRequestBlock.setOnClickListener { binding.messageRequestBlock.setOnClickListener {
block(deleteThread = true) block(deleteThread = true)
} }
binding.declineMessageRequestButton.setOnClickListener { binding.declineMessageRequestButton.setOnClickListener {
viewModel.declineMessageRequest() viewModel.declineMessageRequest()
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
}
finish()
}
} }
private fun acceptMessageRequest() { lifecycleScope.launch {
binding.messageRequestBar.isVisible = false repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.acceptMessageRequest() viewModel.uiState
.map { it.messageRequestState }
.distinctUntilChanged()
.collectLatest { state ->
binding.messageRequestBar.isVisible = state != MessageRequestUiState.Invisible
lifecycleScope.launch(Dispatchers.IO) { if (state is MessageRequestUiState.Visible) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) binding.sendAcceptsTextView.setText(state.acceptButtonText)
binding.messageRequestBlock.isVisible = state.showBlockButton
binding.declineMessageRequestButton.setText(state.declineButtonText)
}
}
}
} }
} }
private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run {
!isGroupRecipient && !isLocalNumber &&
!(hasApprovedMe() || viewModel.hasReceived())
} ?: false
private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run {
!isGroupRecipient && !isApproved && !isLocalNumber &&
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0
} ?: false
override fun inputBarEditTextContentChanged(newContent: CharSequence) { override fun inputBarEditTextContentChanged(newContent: CharSequence) {
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
@ -1174,20 +1222,35 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} ?: false } ?: false
} }
override fun onClick(v: View?) {
if (v === binding?.toolbarContent?.profilePictureView) {
// open conversation settings
conversationSettingsCallback.launch(viewModel.threadId)
}
}
override fun block(deleteThread: Boolean) { override fun block(deleteThread: Boolean) {
val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action") val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action")
val invitingAdmin = viewModel.invitingAdmin
val name = if (recipient.isClosedGroupV2Recipient && invitingAdmin != null) {
invitingAdmin.getSearchName()
} else {
recipient.name
}
showSessionDialog { showSessionDialog {
title(R.string.block) title(R.string.block)
text( text(
Phrase.from(context, R.string.blockDescription) Phrase.from(context, R.string.blockDescription)
.put(NAME_KEY, recipient.name) .put(NAME_KEY, name)
.format() .format()
) )
dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) {
viewModel.block() viewModel.block()
// Block confirmation toast added as per SS-64 // Block confirmation toast added as per SS-64
val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, recipient.name).format().toString() val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, name).format().toString()
Toast.makeText(context, txt, Toast.LENGTH_LONG).show() Toast.makeText(context, txt, Toast.LENGTH_LONG).show()
if (deleteThread) { if (deleteThread) {
@ -1218,8 +1281,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show()
} }
// TODO: don't need to allow new closed group check here, removed in new disappearing messages
override fun showDisappearingMessages(thread: Recipient) { override fun showDisappearingMessages(thread: Recipient) {
if (thread.isClosedGroupRecipient) { if (thread.isLegacyClosedGroupRecipient) {
groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return } groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
} }
Intent(this, DisappearingMessagesActivity::class.java) Intent(this, DisappearingMessagesActivity::class.java)
@ -1687,19 +1751,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY)
} }
private fun processMessageRequestApproval() {
if (isIncomingMessageRequestThread()) {
acceptMessageRequest()
} else if (viewModel.recipient?.isApproved == false) {
// edge case for new outgoing thread on new recipient without sending approval messages
viewModel.setRecipientApproved()
}
}
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? { private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return null val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() viewModel.beforeSendingTextOnlyMessage()
val text = getMessageBody() val text = getMessageBody()
val userPublicKey = textSecurePreferences.getLocalNumber() val userPublicKey = textSecurePreferences.getLocalNumber()
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
@ -1743,7 +1798,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
): Pair<Address, Long>? { ): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return null val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() viewModel.beforeSendingAttachments()
// Create the message // Create the message
val message = VisibleMessage().applyExpiryMode(viewModel.threadId) val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
message.sentTimestamp = sentTimestamp message.sentTimestamp = sentTimestamp
@ -2088,7 +2143,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
cancelButton { endActionMode() } cancelButton { endActionMode() }
} }
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone // Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
} else if (allSentByCurrentUser && allHasHash) { } else if ((allSentByCurrentUser || viewModel.isClosedGroupAdmin) && allHasHash) {
val bottomSheet = DeleteOptionsBottomSheet() val bottomSheet = DeleteOptionsBottomSheet()
bottomSheet.recipient = recipient bottomSheet.recipient = recipient
bottomSheet.onDeleteForMeTapped = { bottomSheet.onDeleteForMeTapped = {

View File

@ -1,51 +1,60 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.Context
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.GroupMember
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.MmsDatabase import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage, private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider, private val messageDataProvider: MessageDataProvider,
database: MmsDatabase, private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase,
private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) private val _uiState = MutableStateFlow(ConversationUiState())
val uiState: StateFlow<ConversationUiState> = _uiState val uiState: StateFlow<ConversationUiState> get() = _uiState
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce { private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId) repository.maybeGetRecipientForThreadId(threadId)
@ -65,12 +74,39 @@ class ConversationViewModel(
} }
} }
/**
* The admin who invites us to this group(v2) conversation.
*
* null if this convo is not a group(v2) conversation, or error getting the info
*/
val invitingAdmin: Recipient?
get() {
val recipient = recipient ?: return null
if (!recipient.isClosedGroupV2Recipient) return null
return repository.getInvitingAdmin(threadId)
}
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce { private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
storage.getOpenGroup(threadId) storage.getOpenGroup(threadId)
} }
val openGroup: OpenGroup? val openGroup: OpenGroup?
get() = _openGroup.value get() = _openGroup.value
private val closedGroupMembers: List<GroupMember>
get() {
val recipient = recipient ?: return emptyList()
if (!recipient.isClosedGroupV2Recipient) return emptyList()
return storage.getMembers(recipient.address.serialize())
}
val isClosedGroupAdmin: Boolean
get() {
val recipient = recipient ?: return false
return !recipient.isClosedGroupV2Recipient ||
(closedGroupMembers.firstOrNull { it.sessionId == storage.getUserPublicKey() }?.admin ?: false)
}
val serverCapabilities: List<String> val serverCapabilities: List<String>
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf() get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
@ -83,7 +119,7 @@ class ConversationViewModel(
val isMessageRequestThread : Boolean val isMessageRequestThread : Boolean
get() { get() {
val recipient = recipient ?: return false val recipient = recipient ?: return false
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved
} }
val canReactToMessages: Boolean val canReactToMessages: Boolean
@ -97,16 +133,99 @@ class ConversationViewModel(
) )
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.Default) {
repository.recipientUpdateFlow(threadId) repository.recipientUpdateFlow(threadId)
.collect { recipient -> .collect { recipient ->
if (recipient == null && _uiState.value.conversationExists) { _uiState.update {
_uiState.update { it.copy(conversationExists = false) } it.copy(
shouldExit = recipient == null,
showInput = shouldShowInput(recipient),
enableInputMediaControls = shouldEnableInputMediaControls(recipient),
messageRequestState = buildMessageRequestState(recipient),
)
} }
} }
} }
} }
/**
* Determines if the input media controls should be enabled.
*
* Normally we will show the input media controls, only in these situations we hide them:
* 1. First time we send message to a person.
* Since we haven't been approved by them, we can't send them any media, only text
*/
private fun shouldEnableInputMediaControls(recipient: Recipient?): Boolean {
if (recipient != null &&
(recipient.is1on1 && !recipient.isLocalNumber) &&
!recipient.hasApprovedMe()) {
return false
}
return true
}
/**
* Determines if the input bar should be shown.
*
* For these situations we hide the input bar:
* 1. The user has been kicked from a group(v2), OR
* 2. The legacy group is inactive, OR
* 3. The community chat is read only
*/
private fun shouldShowInput(recipient: Recipient?): Boolean {
return when {
recipient?.isClosedGroupV2Recipient == true -> !repository.isKicked(recipient)
recipient?.isLegacyClosedGroupRecipient == true -> {
groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true
}
openGroup != null -> openGroup?.canWrite == true
else -> true
}
}
private fun buildMessageRequestState(recipient: Recipient?): MessageRequestUiState {
// The basic requirement of showing a message request is:
// 1. The other party has not been approved by us, AND
// 2. We haven't sent a message to them before (if we do, we would be the one requesting permission), AND
// 3. We have received message from them AND
// 4. The type of conversation supports message request (only 1to1 and groups v2)
if (
recipient != null &&
// Req 1: we haven't approved the other party
(!recipient.isApproved && !recipient.isLocalNumber) &&
// Req 4: the type of conversation supports message request
(recipient.is1on1 || recipient.isClosedGroupV2Recipient) &&
// Req 2: we haven't sent a message to them before
!threadDb.getLastSeenAndHasSent(threadId).second() &&
// Req 3: we have received message from them
threadDb.getMessageCount(threadId) > 0
) {
return MessageRequestUiState.Visible(
acceptButtonText = if (recipient.isGroupRecipient) {
R.string.messageRequestGroupInviteDescription
} else {
R.string.messageRequestsAcceptDescription
},
// You can block a 1to1 conversation, or a normal groups v2 conversation
showBlockButton = recipient.is1on1 || recipient.isClosedGroupV2Recipient,
declineButtonText = if (recipient.isClosedGroupV2Recipient) {
R.string.delete
} else {
R.string.decline
}
)
}
return MessageRequestUiState.Invisible
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
@ -135,16 +254,17 @@ class ConversationViewModel(
} }
fun block() { fun block() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action") // inviting admin will be true if this request is a closed group message request
if (recipient.isContactRecipient) { val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action")
repository.setBlocked(recipient, true) if (recipient.isContactRecipient || recipient.isClosedGroupV2Recipient) {
repository.setBlocked(threadId, recipient, true)
} }
} }
fun unblock() { fun unblock() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
if (recipient.isContactRecipient) { if (recipient.isContactRecipient) {
repository.setBlocked(recipient, false) repository.setBlocked(threadId, recipient, false)
} }
} }
@ -167,11 +287,6 @@ class ConversationViewModel(
AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop() AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop()
} }
fun setRecipientApproved() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action")
repository.setApproved(recipient, true)
}
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
stopPlayingAudioMessage(message) stopPlayingAudioMessage(message)
@ -221,19 +336,36 @@ class ConversationViewModel(
fun acceptMessageRequest() = viewModelScope.launch { fun acceptMessageRequest() = viewModelScope.launch {
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action") val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action")
val currentState = _uiState.value.messageRequestState as? MessageRequestUiState.Visible
?: return@launch Log.w("Loki", "Current state was not visible for accept message request action")
_uiState.update {
it.copy(messageRequestState = MessageRequestUiState.Pending(currentState))
}
repository.acceptMessageRequest(threadId, recipient) repository.acceptMessageRequest(threadId, recipient)
.onSuccess { .onSuccess {
_uiState.update { _uiState.update {
it.copy(isMessageRequestAccepted = true) it.copy(messageRequestState = MessageRequestUiState.Invisible)
}
withContext(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
} }
} }
.onFailure { .onFailure {
showMessage("Couldn't accept message request due to error: $it") showMessage("Couldn't accept message request due to error: $it")
_uiState.update { state ->
state.copy(messageRequestState = currentState)
}
} }
} }
fun declineMessageRequest() { fun declineMessageRequest() {
repository.declineMessageRequest(threadId) repository.declineMessageRequest(threadId, recipient!!)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
_uiState.update { it.copy(shouldExit = true) }
} }
private fun showMessage(message: String) { private fun showMessage(message: String) {
@ -278,6 +410,25 @@ class ConversationViewModel(
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
} }
fun beforeSendingTextOnlyMessage() {
implicitlyApproveRecipient()
}
fun beforeSendingAttachments() {
implicitlyApproveRecipient()
}
private fun implicitlyApproveRecipient() {
val recipient = recipient
if (uiState.value.messageRequestState is MessageRequestUiState.Visible) {
acceptMessageRequest()
} else if (recipient?.isApproved == false) {
// edge case for new outgoing thread on new recipient without sending approval messages
repository.setApproved(recipient, true)
}
}
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@ -288,9 +439,12 @@ class ConversationViewModel(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage, private val storage: StorageProtocol,
private val mmsDatabase: MmsDatabase,
private val messageDataProvider: MessageDataProvider, private val messageDataProvider: MessageDataProvider,
private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase,
@ApplicationContext
private val context: Context,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
@ -300,7 +454,9 @@ class ConversationViewModel(
repository = repository, repository = repository,
storage = storage, storage = storage,
messageDataProvider = messageDataProvider, messageDataProvider = messageDataProvider,
database = mmsDatabase groupDb = groupDb,
threadDb = threadDb,
appContext = context,
) as T ) as T
} }
} }
@ -310,10 +466,24 @@ data class UiMessage(val id: Long, val message: String)
data class ConversationUiState( data class ConversationUiState(
val uiMessages: List<UiMessage> = emptyList(), val uiMessages: List<UiMessage> = emptyList(),
val isMessageRequestAccepted: Boolean? = null, val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible,
val conversationExists: Boolean val shouldExit: Boolean = false,
val showInput: Boolean = true,
val enableInputMediaControls: Boolean = true,
) )
sealed interface MessageRequestUiState {
data object Invisible : MessageRequestUiState
data class Pending(val prevState: Visible) : MessageRequestUiState
data class Visible(
@StringRes val acceptButtonText: Int,
val showBlockButton: Boolean,
@StringRes val declineButtonText: Int,
) : MessageRequestUiState
}
data class RetrieveOnce<T>(val retrieval: () -> T?) { data class RetrieveOnce<T>(val retrieval: () -> T?) {
private var triedToRetrieve: Boolean = false private var triedToRetrieve: Boolean = false
private var _value: T? = null private var _value: T? = null

View File

@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
if (!this::recipient.isInitialized) { if (!this::recipient.isInitialized) {
return dismiss() return dismiss()
} }
if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { if (recipient.isLocalNumber) {
binding.deleteForEveryoneTextView.text = binding.deleteForEveryoneTextView.text =
resources.getString(R.string.clearMessagesForEveryone, contact) getString(R.string.clearMessagesForMe)
} else if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
binding.deleteForEveryoneTextView.text =
resources.getString(R.string.clearMessagesForEveryone)
} }
binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient binding.deleteForEveryoneTextView.isVisible = !recipient.isLegacyClosedGroupRecipient
binding.deleteForMeTextView.setOnClickListener(this) binding.deleteForMeTextView.setOnClickListener(this)
binding.deleteForEveryoneTextView.setOnClickListener(this) binding.deleteForEveryoneTextView.setOnClickListener(this)
binding.cancelTextView.setOnClickListener(this) binding.cancelTextView.setOnClickListener(this)

View File

@ -54,6 +54,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
@ -84,7 +85,7 @@ import javax.inject.Inject
class MessageDetailActivity : PassphraseRequiredActionBarActivity() { class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Inject @Inject
lateinit var storage: Storage lateinit var storage: StorageProtocol
private val viewModel: MessageDetailsViewModel by viewModels() private val viewModel: MessageDetailsViewModel by viewModels()

View File

@ -10,49 +10,58 @@ import androidx.fragment.app.DialogFragment
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload
import javax.inject.Inject import javax.inject.Inject
/** Shown when receiving media from a contact for the first time, to confirm that /** Shown when receiving media from a contact for the first time, to confirm that
* they are to be trusted and files sent by them are to be downloaded. */ * they are to be trusted and files sent by them are to be downloaded. */
@AndroidEntryPoint @AndroidEntryPoint
class DownloadDialog(private val recipient: Recipient) : DialogFragment() { class AutoDownloadDialog(private val threadRecipient: Recipient,
private val databaseAttachment: DatabaseAttachment
) : DialogFragment() {
@Inject lateinit var storage: StorageProtocol
@Inject lateinit var contactDB: SessionContactDatabase @Inject lateinit var contactDB: SessionContactDatabase
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val accountID = recipient.address.toString() val threadId = storage.getThreadId(threadRecipient) ?: run {
val contact = contactDB.getContactWithAccountID(accountID) dismiss()
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID return@createSessionDialog
}
val displayName = when {
threadRecipient.isCommunityRecipient -> storage.getOpenGroup(threadId)?.name ?: "UNKNOWN"
threadRecipient.isLegacyClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN"
threadRecipient.isClosedGroupV2Recipient -> threadRecipient.name ?: "UNKNOWN"
else -> storage.getContactWithAccountID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN"
}
title(getString(R.string.attachmentsAutoDownloadModalTitle)) title(getString(R.string.attachmentsAutoDownloadModalTitle))
val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription) val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription)
.put(CONVERSATION_NAME_KEY, recipient.name) .put(CONVERSATION_NAME_KEY, displayName)
.format() .format()
val spannable = SpannableStringBuilder(explanation) val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(displayName)
val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text(spannable) text(spannable)
button(R.string.download, R.string.AccessibilityId_download) { trust() } button(R.string.download, R.string.AccessibilityId_download) {
setAutoDownload()
}
cancelButton { dismiss() } cancelButton { dismiss() }
} }
private fun trust() { private fun setAutoDownload() {
val accountID = recipient.address.toString() storage.setAutoDownloadAttachments(threadRecipient, true)
val contact = contactDB.getContactWithAccountID(accountID) ?: return JobQueue.shared.createAndStartAttachmentDownload(databaseAttachment)
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
contactDB.setContactIsTrusted(contact, true, threadID)
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
dismiss()
} }
} }

View File

@ -60,8 +60,13 @@ class InputBar @JvmOverloads constructor(
var delegate: InputBarDelegate? = null var delegate: InputBarDelegate? = null
var quote: MessageRecord? = null var quote: MessageRecord? = null
var linkPreview: LinkPreview? = null var linkPreview: LinkPreview? = null
var showInput: Boolean = true private var showInput: Boolean = true
set(value) { field = value; showOrHideInputIfNeeded() } set(value) {
if (field != value) {
field = value
showOrHideInputIfNeeded()
}
}
var showMediaControls: Boolean = true var showMediaControls: Boolean = true
set(value) { set(value) {
field = value field = value
@ -252,20 +257,20 @@ class InputBar @JvmOverloads constructor(
} }
private fun showOrHideInputIfNeeded() { private fun showOrHideInputIfNeeded() {
if (showInput) { if (!showInput) {
setOf( binding.inputBarEditText, attachmentsButton ).forEach { it.isVisible = true }
microphoneButton.isVisible = text.isEmpty()
sendButton.isVisible = text.isNotEmpty()
} else {
cancelQuoteDraft() cancelQuoteDraft()
cancelLinkPreviewDraft() cancelLinkPreviewDraft()
val views = setOf( binding.inputBarEditText, attachmentsButton, microphoneButton, sendButton )
views.forEach { it.isVisible = false }
} }
binding.inputBarEditText.isVisible = showInput
attachmentsButton.isVisible = showInput
microphoneButton.isVisible = showInput && text.isEmpty()
sendButton.isVisible = showInput && text.isNotEmpty()
} }
private fun showOrHideMediaControlsIfNeeded() { private fun showOrHideMediaControlsIfNeeded() {
setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls } attachmentsButton.snIsEnabled = showMediaControls
microphoneButton.snIsEnabled = showMediaControls
} }
fun addTextChangedListener(listener: (String) -> Unit) { fun addTextChangedListener(listener: (String) -> Unit) {

View File

@ -85,10 +85,13 @@ class MentionViewModel(
} }
val memberIDs = when { val memberIDs = when {
recipient.isClosedGroupRecipient -> { recipient.isLegacyClosedGroupRecipient -> {
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false) groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
.map { it.serialize() } .map { it.serialize() }
} }
recipient.isClosedGroupV2Recipient -> {
storage.getMembers(recipient.address.serialize()).map { it.sessionId }
}
recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20) recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20)
recipient.isContactRecipient -> listOf(recipient.address.serialize()) recipient.isContactRecipient -> listOf(recipient.address.serialize())

View File

@ -6,10 +6,10 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
@ -37,7 +37,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!! val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!!
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! val edKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString

View File

@ -34,8 +34,8 @@ import org.thoughtcrime.securesms.contacts.SelectContactsActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showMuteDialog
@ -56,7 +56,7 @@ object ConversationMenuHelper {
// Base menu (options that should always be present) // Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu) inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages // Expiring messages
if (!isCommunity && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyClosedGroupRecipient || thread.isLocalNumber)) {
inflater.inflate(R.menu.menu_conversation_expiration, menu) inflater.inflate(R.menu.menu_conversation_expiration, menu)
} }
// One-on-one chat menu allows copying the account id // One-on-one chat menu allows copying the account id
@ -72,7 +72,7 @@ object ConversationMenuHelper {
} }
} }
// Closed group menu (options that should only be present in closed groups) // Closed group menu (options that should only be present in closed groups)
if (thread.isClosedGroupRecipient) { if (thread.isLegacyClosedGroupRecipient) {
inflater.inflate(R.menu.menu_conversation_closed_group, menu) inflater.inflate(R.menu.menu_conversation_closed_group, menu)
} }
// Open group menu // Open group menu
@ -258,15 +258,15 @@ object ConversationMenuHelper {
} }
private fun editClosedGroup(context: Context, thread: Recipient) { private fun editClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return } if (!thread.isLegacyClosedGroupRecipient) { return }
val intent = Intent(context, EditClosedGroupActivity::class.java) val intent = Intent(context, EditLegacyGroupActivity::class.java)
val groupID: String = thread.address.toGroupString() val groupID: String = thread.address.toGroupString()
intent.putExtra(groupIDKey, groupID) intent.putExtra(groupIDKey, groupID)
context.startActivity(intent) context.startActivity(intent)
} }
private fun leaveClosedGroup(context: Context, thread: Recipient) { private fun leaveClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return } if (!thread.isLegacyClosedGroupRecipient) { return }
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins val admins = group.admins

View File

@ -16,6 +16,8 @@ import network.loki.messenger.databinding.ViewControlMessageBinding
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
@ -45,6 +47,7 @@ class ControlMessageView : LinearLayout {
binding.expirationTimerView.isGone = true binding.expirationTimerView.isGone = true
binding.followSetting.isGone = true binding.followSetting.isGone = true
var messageBody: CharSequence = message.getDisplayBody(context) var messageBody: CharSequence = message.getDisplayBody(context)
binding.root.contentDescription = null binding.root.contentDescription = null
binding.textView.text = messageBody binding.textView.text = messageBody
when { when {
@ -54,7 +57,7 @@ class ControlMessageView : LinearLayout {
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
if (threadRecipient?.isClosedGroupRecipient == true) { if (threadRecipient?.isClosedGroupV2Recipient == true) {
expirationTimerView.setTimerIcon() expirationTimerView.setTimerIcon()
} else { } else {
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
@ -98,6 +101,12 @@ class ControlMessageView : LinearLayout {
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
} }
} }
message.isGroupUpdateMessage -> {
val updateMessageData: UpdateMessageData? = UpdateMessageData.fromJSON(message.body)
if (updateMessageData?.isGroupErrorQuitKind() == true) {
binding.textView.setTextColor(context.getColorFromAttr(R.attr.danger))
}
}
} }
binding.textView.isGone = message.isCallLog binding.textView.isGone = message.isCallLog

View File

@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewPendingAttachmentBinding
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.dialogs.AutoDownloadDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.displaySize
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class PendingAttachmentView: LinearLayout {
private val binding by lazy { ViewPendingAttachmentBinding.bind(this) }
enum class AttachmentType {
AUDIO,
DOCUMENT,
IMAGE,
VIDEO,
}
// region Lifecycle
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
// endregion
@Inject lateinit var storage: StorageProtocol
// region Updating
fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int, attachment: DatabaseAttachment) {
val stringRes = when (attachmentType) {
AttachmentType.AUDIO -> R.string.audio
AttachmentType.DOCUMENT -> R.string.document
AttachmentType.IMAGE -> R.string.image
AttachmentType.VIDEO -> R.string.video
}
val text = Phrase.from(context, R.string.attachmentsTapToDownload)
.put(FILE_TYPE_KEY, context.getString(stringRes).lowercase(Locale.ROOT))
.format()
binding.pendingDownloadIcon.setColorFilter(textColor)
binding.pendingDownloadSize.text = attachment.displaySize()
binding.pendingDownloadTitle.text = text
}
// endregion
// region Interaction
fun showDownloadDialog(threadRecipient: Recipient, attachment: DatabaseAttachment) {
if (!storage.shouldAutoDownloadAttachments(threadRecipient)) {
// just download
ActivityDispatcher.get(context)?.showDialog(AutoDownloadDialog(threadRecipient, attachment))
}
}
}

View File

@ -1,56 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import com.squareup.phrase.Phrase
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewUntrustedAttachmentBinding
import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher
class UntrustedAttachmentView: LinearLayout {
private val binding: ViewUntrustedAttachmentBinding by lazy { ViewUntrustedAttachmentBinding.bind(this) }
enum class AttachmentType {
AUDIO,
DOCUMENT,
MEDIA
}
// region Lifecycle
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
// endregion
// region Updating
fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int) {
val (iconRes, stringRes) = when (attachmentType) {
AttachmentType.AUDIO -> R.drawable.ic_microphone to R.string.audio
AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.files
AttachmentType.MEDIA -> R.drawable.ic_image_white_24dp to R.string.media
}
val iconDrawable = ContextCompat.getDrawable(context,iconRes)!!
iconDrawable.mutate().setTint(textColor)
val text = Phrase.from(context, R.string.attachmentsTapToDownload)
.put(FILE_TYPE_KEY, context.getString(stringRes))
.format()
binding.untrustedAttachmentTitle.text = text
binding.untrustedAttachmentIcon.setImageDrawable(iconDrawable)
binding.untrustedAttachmentTitle.text = text
}
// endregion
// region Interaction
fun showTrustDialog(recipient: Recipient) {
ActivityDispatcher.get(context)?.showDialog(DownloadDialog(recipient))
}
}

View File

@ -23,6 +23,7 @@ import com.bumptech.glide.RequestManager
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
@ -34,7 +35,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
@ -61,7 +61,6 @@ class VisibleMessageContentView : ConstraintLayout {
glide: RequestManager = Glide.with(this), glide: RequestManager = Glide.with(this),
thread: Recipient, thread: Recipient,
searchQuery: String? = null, searchQuery: String? = null,
contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
suppressThumbnails: Boolean = false suppressThumbnails: Boolean = false
) { ) {
@ -71,8 +70,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.contentParent.mainColor = color binding.contentParent.mainColor = color
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
val onlyBodyMessage = message is SmsMessageRecord val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE }
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress }
val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
// reset visibilities / containers // reset visibilities / containers
onContentClick.clear() onContentClick.clear()
@ -85,7 +85,6 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.isVisible = false binding.bodyTextView.isVisible = false
binding.quoteView.root.isVisible = false binding.quoteView.root.isVisible = false
binding.linkPreviewView.root.isVisible = false binding.linkPreviewView.root.isVisible = false
binding.untrustedView.root.isVisible = false
binding.voiceMessageView.root.isVisible = false binding.voiceMessageView.root.isVisible = false
binding.documentView.root.isVisible = false binding.documentView.root.isVisible = false
binding.albumThumbnailView.root.isVisible = false binding.albumThumbnailView.root.isVisible = false
@ -100,9 +99,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.text = null binding.bodyTextView.text = null
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
@ -140,6 +139,7 @@ class VisibleMessageContentView : ConstraintLayout {
} }
when { when {
// LINK PREVIEW
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
@ -147,10 +147,11 @@ class VisibleMessageContentView : ConstraintLayout {
// When in a link preview ensure the bodyTextView can expand to the full width // When in a link preview ensure the bodyTextView can expand to the full width
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
} }
// AUDIO
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
hideBody = true hideBody = true
// Audio attachment // Audio attachment
if (contactIsTrusted || message.isOutgoing) { if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
binding.voiceMessageView.root.indexInAdapter = indexInAdapter binding.voiceMessageView.root.indexInAdapter = indexInAdapter
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2 binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
@ -159,26 +160,38 @@ class VisibleMessageContentView : ConstraintLayout {
onContentClick.add { binding.voiceMessageView.root.togglePlayback() } onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
} else { } else {
// TODO: move this out to its own area
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
}
}
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
hideBody = true hideBody = true
(message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
binding.pendingAttachmentView.root.bind(
PendingAttachmentView.AttachmentType.AUDIO,
getTextColor(context,message),
attachment
)
onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
}
}
}
// DOCUMENT
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
hideBody = true // TODO: check if this is still the logic we want
// Document attachment // Document attachment
if (contactIsTrusted || message.isOutgoing) { if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) binding.documentView.root.bind(message, getTextColor(context, message))
} else { } else {
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) hideBody = true
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
binding.pendingAttachmentView.root.bind(
PendingAttachmentView.AttachmentType.DOCUMENT,
getTextColor(context,message),
attachment
)
onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
} }
} }
}
// IMAGE / VIDEO
message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
/* if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
* Images / Video attachment
*/
if (contactIsTrusted || message.isOutgoing) {
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// bind after add view because views are inflated and calculated during bind // bind after add view because views are inflated and calculated during bind
binding.albumThumbnailView.root.bind( binding.albumThumbnailView.root.bind(
@ -196,13 +209,22 @@ class VisibleMessageContentView : ConstraintLayout {
} else { } else {
hideBody = true hideBody = true
binding.albumThumbnailView.root.clearViews() binding.albumThumbnailView.root.clearViews()
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } firstAttachment?.let { attachment ->
binding.pendingAttachmentView.root.bind(
PendingAttachmentView.AttachmentType.IMAGE,
getTextColor(context,message),
attachment
)
onContentClick.add {
binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment)
}
}
} }
} }
message.isOpenGroupInvitation -> { message.isOpenGroupInvitation -> {
hideBody = true hideBody = true
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) binding.openGroupInvitationView.root.bind(message, getTextColor(context, message))
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() } onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
} }
} }
@ -239,7 +261,7 @@ class VisibleMessageContentView : ConstraintLayout {
fun recycle() { fun recycle() {
arrayOf( arrayOf(
binding.deletedMessageView.root, binding.deletedMessageView.root,
binding.untrustedView.root, binding.pendingAttachmentView.root,
binding.voiceMessageView.root, binding.voiceMessageView.root,
binding.openGroupInvitationView.root, binding.openGroupInvitationView.root,
binding.documentView.root, binding.documentView.root,

View File

@ -259,7 +259,6 @@ class VisibleMessageView : FrameLayout {
glide, glide,
thread, thread,
searchQuery, searchQuery,
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
onAttachmentNeedsDownload onAttachmentNeedsDownload
) )
binding.messageContentView.root.delegate = delegate binding.messageContentView.root.delegate = delegate

View File

@ -35,6 +35,12 @@ import org.session.libsignal.utilities.Base64;
import java.io.IOException; import java.io.IOException;
import kotlin.Unit;
import kotlinx.coroutines.channels.BufferOverflow;
import kotlinx.coroutines.flow.MutableSharedFlow;
import kotlinx.coroutines.flow.MutableStateFlow;
import kotlinx.coroutines.flow.SharedFlowKt;
/** /**
* Utility class for working with identity keys. * Utility class for working with identity keys.
* *
@ -56,6 +62,8 @@ public class IdentityKeyUtil {
public static final String LOKI_SEED = "loki_seed"; public static final String LOKI_SEED = "loki_seed";
public static final String HAS_MIGRATED_KEY = "has_migrated_keys"; public static final String HAS_MIGRATED_KEY = "has_migrated_keys";
public static final MutableSharedFlow<Unit> CHANGES = SharedFlowKt.MutableSharedFlow(0, 1, BufferOverflow.DROP_LATEST);
private static SharedPreferences getSharedPreferences(Context context) { private static SharedPreferences getSharedPreferences(Context context) {
return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0); return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
} }
@ -158,9 +166,11 @@ public class IdentityKeyUtil {
} }
if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences"); if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences");
CHANGES.tryEmit(Unit.INSTANCE);
} }
public static void delete(Context context, String key) { public static void delete(Context context, String key) {
context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0).edit().remove(key).commit(); context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0).edit().remove(key).commit();
CHANGES.tryEmit(Unit.INSTANCE);
} }
} }

View File

@ -4,6 +4,9 @@ import android.content.Context
import androidx.core.content.contentValuesOf import androidx.core.content.contentValuesOf
import androidx.core.database.getBlobOrNull import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull import androidx.core.database.getLongOrNull
import androidx.sqlite.db.transaction
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
@ -20,6 +23,11 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));" "CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?" private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?"
val KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name
val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name
val MEMBER_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name
} }
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) { fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
@ -33,6 +41,49 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey)) db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
} }
fun deleteGroupConfigs(closedGroupId: AccountId) {
val db = writableDatabase
db.transaction {
val variants = arrayOf(KEYS_VARIANT, INFO_VARIANT, MEMBER_VARIANT)
db.delete(TABLE_NAME, VARIANT_IN_AND_PUBKEY_WHERE,
arrayOf(variants, closedGroupId.hexString)
)
}
}
fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) {
val db = writableDatabase
db.transaction {
val keyContent = contentValuesOf(
VARIANT to KEYS_VARIANT,
PUBKEY to publicKey,
DATA to keysConfig,
TIMESTAMP to timestamp
)
db.insertOrUpdate(TABLE_NAME, keyContent, VARIANT_AND_PUBKEY_WHERE,
arrayOf(KEYS_VARIANT, publicKey)
)
val infoContent = contentValuesOf(
VARIANT to INFO_VARIANT,
PUBKEY to publicKey,
DATA to infoConfig,
TIMESTAMP to timestamp
)
db.insertOrUpdate(TABLE_NAME, infoContent, VARIANT_AND_PUBKEY_WHERE,
arrayOf(INFO_VARIANT, publicKey)
)
val memberContent = contentValuesOf(
VARIANT to MEMBER_VARIANT,
PUBKEY to publicKey,
DATA to memberConfig,
TIMESTAMP to timestamp
)
db.insertOrUpdate(TABLE_NAME, memberContent, VARIANT_AND_PUBKEY_WHERE,
arrayOf(MEMBER_VARIANT, publicKey)
)
}
}
fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? { fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
val db = readableDatabase val db = readableDatabase
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)

View File

@ -5,7 +5,7 @@ import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata
import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX import org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX
import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX
import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@ -29,7 +29,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """ val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%' WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$LEGACY_CLOSED_GROUP_PREFIX%'
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
""".trimIndent() """.trimIndent()
@ -37,7 +37,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """ val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%' WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$LEGACY_CLOSED_GROUP_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%'
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
import org.session.libsession.database.ServerHashToMessageId
import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.database.LokiMessageDatabaseProtocol
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@ -16,6 +17,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
private val messageHashTable = "loki_message_hash_database" private val messageHashTable = "loki_message_hash_database"
private val smsHashTable = "loki_sms_hash_database" private val smsHashTable = "loki_sms_hash_database"
private val mmsHashTable = "loki_mms_hash_database" private val mmsHashTable = "loki_mms_hash_database"
const val groupInviteTable = "loki_group_invites"
private val groupInviteDeleteTrigger = "group_invite_delete_trigger"
private val messageID = "message_id" private val messageID = "message_id"
private val serverID = "server_id" private val serverID = "server_id"
private val friendRequestStatus = "friend_request_status" private val friendRequestStatus = "friend_request_status"
@ -23,6 +28,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
private val errorMessage = "error_message" private val errorMessage = "error_message"
private val messageType = "message_type" private val messageType = "message_type"
private val serverHash = "server_hash" private val serverHash = "server_hash"
const val invitingSessionId = "inviting_session_id"
@JvmStatic @JvmStatic
val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);" val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);"
@JvmStatic @JvmStatic
@ -39,6 +46,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
@JvmStatic @JvmStatic
val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
@JvmStatic
val createGroupInviteTableCommand = "CREATE TABLE IF NOT EXISTS $groupInviteTable ($threadID INTEGER PRIMARY KEY, $invitingSessionId STRING);"
@JvmStatic
val createThreadDeleteTrigger = "CREATE TRIGGER IF NOT EXISTS $groupInviteDeleteTrigger AFTER DELETE ON ${ThreadDatabase.TABLE_NAME} BEGIN DELETE FROM $groupInviteTable WHERE $threadID = OLD.${ThreadDatabase.ID}; END;"
const val SMS_TYPE = 0 const val SMS_TYPE = 0
const val MMS_TYPE = 1 const val MMS_TYPE = 1
@ -224,6 +235,49 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
} }
} }
fun getSendersForHashes(threadId: Long, hashes: Set<String>): List<ServerHashToMessageId> {
val smsQuery = "SELECT ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ADDRESS}, $smsHashTable.$serverHash, " +
"${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} FROM $smsHashTable LEFT OUTER JOIN ${SmsDatabase.TABLE_NAME} " +
"ON ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} = $smsHashTable.$messageID WHERE ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.THREAD_ID} = ?;"
val mmsQuery = "SELECT ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ADDRESS}, $mmsHashTable.$serverHash, " +
"${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} FROM $mmsHashTable LEFT OUTER JOIN ${MmsDatabase.TABLE_NAME} " +
"ON ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} = $mmsHashTable.$messageID WHERE ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.THREAD_ID} = ?;"
val smsCursor = databaseHelper.readableDatabase.query(smsQuery, arrayOf(threadId))
val mmsCursor = databaseHelper.readableDatabase.query(mmsQuery, arrayOf(threadId))
val serverHashToMessageIds = mutableListOf<ServerHashToMessageId>()
smsCursor.use { cursor ->
while (cursor.moveToNext()) {
val hash = cursor.getString(1)
if (hash in hashes) {
serverHashToMessageIds += ServerHashToMessageId(
serverHash = hash,
isSms = true,
sender = cursor.getString(0),
messageId = cursor.getLong(2)
)
}
}
}
mmsCursor.use { cursor ->
while (cursor.moveToNext()) {
val hash = cursor.getString(1)
if (hash in hashes) {
serverHashToMessageIds += ServerHashToMessageId(
serverHash = hash,
isSms = false,
sender = cursor.getString(0),
messageId = cursor.getLong(2)
)
}
}
}
return serverHashToMessageIds
}
fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull { fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull {
databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
cursor.getString(serverHash) cursor.getString(serverHash)
@ -255,6 +309,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
) )
} }
fun addGroupInviteReferrer(groupThreadId: Long, referrerSessionId: String) {
val contentValues = ContentValues(2).apply {
put(threadID, groupThreadId)
put(invitingSessionId, referrerSessionId)
}
databaseHelper.writableDatabase.insertOrUpdate(
groupInviteTable, contentValues, "$threadID = ?", arrayOf(groupThreadId.toString())
)
}
fun groupInviteReferrer(groupThreadId: Long): String? {
return databaseHelper.readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) {cursor ->
cursor.getString(invitingSessionId)
}
}
fun deleteGroupInviteReferrer(groupThreadId: Long) {
databaseHelper.writableDatabase.delete(
groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())
)
}
private fun getMessageTables(mms: Boolean) = sequenceOf( private fun getMessageTables(mms: Boolean) = sequenceOf(
getMessageTable(mms), getMessageTable(mms),
messageHashTable messageHashTable

View File

@ -44,7 +44,8 @@ public class MediaDatabase extends Database {
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + " " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.LINK_PREVIEWS + " "
+ "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME + "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID + "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID
@ -52,7 +53,8 @@ public class MediaDatabase extends Database {
+ " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND " + " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND "
+ AttachmentDatabase.DATA + " IS NOT NULL AND " + AttachmentDatabase.DATA + " IS NOT NULL AND "
+ AttachmentDatabase.QUOTE + " = 0 AND " + AttachmentDatabase.QUOTE + " = 0 AND "
+ AttachmentDatabase.STICKER_PACK_ID + " IS NULL " + AttachmentDatabase.STICKER_PACK_ID + " IS NULL AND "
+ MmsDatabase.LINK_PREVIEWS + " IS NULL "
+ "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC"; + "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC";
private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'"); private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'");

View File

@ -14,7 +14,6 @@ import org.session.libsession.utilities.IdentityKeyMismatchList;
import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.crypto.IdentityKey;
import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.SqlUtil;
@ -46,7 +45,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markUnidentified(long messageId, boolean unidentified); public abstract void markUnidentified(long messageId, boolean unidentified);
public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention); public abstract void markAsDeleted(long messageId);
public abstract boolean deleteMessage(long messageId); public abstract boolean deleteMessage(long messageId);
public abstract boolean deleteMessages(long[] messageId, long threadId); public abstract boolean deleteMessages(long[] messageId, long threadId);
@ -55,6 +54,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException; public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
public abstract String getTypeColumn();
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) { public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
try { try {
addToDocument(messageId, MISMATCHED_IDENTITIES, addToDocument(messageId, MISMATCHED_IDENTITIES,
@ -206,6 +207,19 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
contentValues.put(THREAD_ID, newThreadId); contentValues.put(THREAD_ID, newThreadId);
db.update(getTableName(), contentValues, where, args); db.update(getTableName(), contentValues, where, args);
} }
public boolean isOutgoing(long messageId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
try(Cursor cursor = db.query(getTableName(), new String[]{getTypeColumn()},
ID_WHERE, new String[]{String.valueOf(messageId)},
null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(0));
}
}
return false;
}
public static class SyncMessageId { public static class SyncMessageId {
private final Address address; private final Address address;

View File

@ -158,7 +158,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
get(context).groupReceiptDatabase() get(context).groupReceiptDatabase()
.update(ourAddress, id, status, timestamp) .update(ourAddress, id, status, timestamp)
get(context).threadDatabase().update(threadId, false, true) get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
} }
@ -178,6 +178,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
} }
fun updateInfoMessage(messageId: Long, body: String?, runThreadUpdate: Boolean = true) {
val threadId = getThreadIdForMessage(messageId)
val db = databaseHelper.writableDatabase
db.execSQL(
"UPDATE $TABLE_NAME SET $BODY = ? WHERE $ID = ?",
arrayOf(body, messageId.toString())
)
with (get(context).threadDatabase()) {
setLastSeen(threadId)
setHasSent(threadId, true)
if (runThreadUpdate) {
update(threadId, true)
}
}
}
fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) { fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) {
val db = databaseHelper.writableDatabase val db = databaseHelper.writableDatabase
db.execSQL( db.execSQL(
@ -257,7 +273,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
" WHERE " + ID + " = ?", arrayOf(id.toString() + "") " WHERE " + ID + " = ?", arrayOf(id.toString() + "")
) )
if (threadId.isPresent) { if (threadId.isPresent) {
get(context).threadDatabase().update(threadId.get(), false, true) get(context).threadDatabase().update(threadId.get(), false)
} }
} }
@ -304,7 +320,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString()))
} }
override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) { override fun markAsDeleted(messageId: Long) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(READ, 1) contentValues.put(READ, 1)
@ -626,7 +642,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
if (runThreadUpdate) { if (runThreadUpdate) {
get(context).threadDatabase().update(threadId, true, true) get(context).threadDatabase().update(threadId, true)
} }
} }
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
@ -771,7 +787,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
setHasSent(threadId, true) setHasSent(threadId, true)
if (runThreadUpdate) { if (runThreadUpdate) {
update(threadId, true, true) update(threadId, true)
} }
} }
return messageId return messageId
@ -851,23 +867,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
} }
private fun deleteQuotedFromMessages(toDeleteRecords: List<MessageRecord>) {
if (toDeleteRecords.isEmpty()) return
val queryBuilder = StringBuilder()
for (i in toDeleteRecords.indices) {
queryBuilder.append("$QUOTE_ID = ").append(toDeleteRecords[i].getId())
if (i + 1 < toDeleteRecords.size) {
queryBuilder.append(" OR ")
}
}
val query = queryBuilder.toString()
val db = databaseHelper.writableDatabase
val values = ContentValues(2)
values.put(QUOTE_MISSING, 1)
values.put(QUOTE_AUTHOR, "")
db!!.update(TABLE_NAME, values, query, null)
}
/** /**
* Delete all the messages in single queries where possible * Delete all the messages in single queries where possible
* @param messageIds a String array representation of regularly Long types representing message IDs * @param messageIds a String array representation of regularly Long types representing message IDs
@ -900,6 +899,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyStickerPackListeners() notifyStickerPackListeners()
} }
override fun getTypeColumn(): String = MESSAGE_BOX
// Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?" // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
// - it is "Was the thread deleted because removing that message resulted in an empty thread"! // - it is "Was the thread deleted because removing that message resulted in an empty thread"!
override fun deleteMessage(messageId: Long): Boolean { override fun deleteMessage(messageId: Long): Boolean {
@ -909,8 +910,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val groupReceiptDatabase = get(context).groupReceiptDatabase() val groupReceiptDatabase = get(context).groupReceiptDatabase()
groupReceiptDatabase.deleteRowsForMessage(messageId) groupReceiptDatabase.deleteRowsForMessage(messageId)
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) database.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
val threadDeleted = get(context).threadDatabase().update(threadId, false, true) val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
notifyStickerListeners() notifyStickerListeners()
notifyStickerPackListeners() notifyStickerPackListeners()
@ -921,6 +922,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val argsArray = messageIds.map { "?" } val argsArray = messageIds.map { "?" }
val argValues = messageIds.map { it.toString() }.toTypedArray() val argValues = messageIds.map { it.toString() }.toTypedArray()
val attachmentDatabase = get(context).attachmentDatabase()
val groupReceiptDatabase = get(context).groupReceiptDatabase()
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
groupReceiptDatabase.deleteRowsForMessages(messageIds)
val db = databaseHelper.writableDatabase val db = databaseHelper.writableDatabase
db.delete( db.delete(
TABLE_NAME, TABLE_NAME,
@ -928,7 +935,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
argValues argValues
) )
val threadDeleted = get(context).threadDatabase().update(threadId, false, true) val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
notifyStickerListeners() notifyStickerListeners()
notifyStickerPackListeners() notifyStickerPackListeners()
@ -956,6 +963,62 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
deleteThreads(setOf(threadId)) deleteThreads(setOf(threadId))
} }
fun deleteMediaFor(threadId: Long, fromUser: String? = null) {
val db = databaseHelper.writableDatabase
val whereString =
if (fromUser == null) "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL"
else "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL"
val whereArgs = if (fromUser == null) arrayOf(threadId.toString()) else arrayOf(threadId.toString(), fromUser)
var cursor: Cursor? = null
try {
cursor = db.query(TABLE_NAME, arrayOf(ID), whereString, whereArgs, null, null, null, null)
val toDeleteStringMessageIds = mutableListOf<String>()
while (cursor.moveToNext()) {
toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
}
// TODO: this can probably be optimized out,
// currently attachmentDB uses MmsID not threadID which makes it difficult to delete
// and clean up on threadID alone
toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
deleteMessages(sublist.toTypedArray())
}
} finally {
cursor?.close()
}
val threadDb = get(context).threadDatabase()
threadDb.update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
}
fun deleteMessagesFrom(threadId: Long, fromUser: String) { // copied from deleteThreads implementation
val db = databaseHelper.writableDatabase
var cursor: Cursor? = null
val whereString = "$THREAD_ID = ? AND $ADDRESS = ?"
try {
cursor =
db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null)
val toDeleteStringMessageIds = mutableListOf<String>()
while (cursor.moveToNext()) {
toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
}
// TODO: this can probably be optimized out,
// currently attachmentDB uses MmsID not threadID which makes it difficult to delete
// and clean up on threadID alone
toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
deleteMessages(sublist.toTypedArray())
}
} finally {
cursor?.close()
}
val threadDb = get(context).threadDatabase()
threadDb.update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
}
private fun getSerializedSharedContacts( private fun getSerializedSharedContacts(
insertedAttachmentIds: Map<Attachment?, AttachmentId?>, insertedAttachmentIds: Map<Attachment?, AttachmentId?>,
contacts: List<Contact?> contacts: List<Contact?>
@ -1099,7 +1162,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return false return false
} }
/*package*/
private fun deleteThreads(threadIds: Set<Long>) { private fun deleteThreads(threadIds: Set<Long>) {
val db = databaseHelper.writableDatabase val db = databaseHelper.writableDatabase
val where = StringBuilder() val where = StringBuilder()
@ -1125,7 +1187,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
val threadDb = get(context).threadDatabase() val threadDb = get(context).threadDatabase()
for (threadId in threadIds) { for (threadId in threadIds) {
val threadDeleted = threadDb.update(threadId, false, true) val threadDeleted = threadDb.update(threadId, false)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
notifyStickerListeners() notifyStickerListeners()
@ -1133,17 +1195,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
/*package*/ /*package*/
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long) { fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, onlyMedia: Boolean) {
var cursor: Cursor? = null var cursor: Cursor? = null
try { try {
val db = databaseHelper.readableDatabase val db = databaseHelper.readableDatabase
var where = var where = "$THREAD_ID = ? AND $DATE_SENT < $date"
THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") " if (onlyMedia) where += " AND $PART_COUNT >= 1"
for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) { cursor = db.query(
where += " WHEN $outgoingType THEN $DATE_SENT < $date"
}
where += " ELSE $DATE_RECEIVED < $date END)"
cursor = db!!.query(
TABLE_NAME, TABLE_NAME,
arrayOf<String?>(ID), arrayOf<String?>(ID),
where, where,

View File

@ -37,7 +37,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.Closeable; import java.io.Closeable;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import kotlin.Pair; import kotlin.Pair;
@ -261,6 +263,23 @@ public class MmsSmsDatabase extends Database {
} }
} }
public List<MessageRecord> getUserMessages(long threadId, String sender) {
List<MessageRecord> idList = new ArrayList<>();
try (Cursor cursor = getConversation(threadId, false)) {
Reader reader = readerFor(cursor);
while (reader.getNext() != null) {
MessageRecord record = reader.getCurrent();
if (record.getIndividualRecipient().getAddress().serialize().equals(sender)) {
idList.add(record);
}
}
}
return idList;
}
// Builds up and returns a list of all all the messages sent by this user in the given thread. // Builds up and returns a list of all all the messages sent by this user in the given thread.
// Used to do a pass through our local database to remove records when a user has "Ban & Delete" // Used to do a pass through our local database to remove records when a user has "Ban & Delete"
// called on them in a Community. // called on them in a Community.

View File

@ -65,13 +65,14 @@ public class RecipientDatabase extends Database {
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash"; private static final String WRAPPER_HASH = "wrapper_hash";
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests"; private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference
private static final String[] RECIPIENT_PROJECTION = new String[] { private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE, UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD,
}; };
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@ -110,6 +111,17 @@ public class RecipientDatabase extends Database {
"ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;"; "ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;";
} }
public static String getCreateAutoDownloadCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + AUTO_DOWNLOAD + " INTEGER DEFAULT -1;";
}
public static String getUpdateAutoDownloadValuesCommand() {
return "UPDATE "+TABLE_NAME+" SET "+AUTO_DOWNLOAD+" = 1 "+
"WHERE "+ADDRESS+" IN (SELECT "+SessionContactDatabase.sessionContactTable+"."+SessionContactDatabase.accountID+" "+
"FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))";
}
public static String getCreateApprovedCommand() { public static String getCreateApprovedCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " + return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;"; "ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
@ -194,6 +206,7 @@ public class RecipientDatabase extends Database {
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE)); int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1;
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR)); String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID)); int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES)); int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
@ -232,7 +245,7 @@ public class RecipientDatabase extends Database {
} }
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil, return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
notifyType, notifyType, autoDownloadAttachments,
Recipient.DisappearingState.fromId(disappearingState), Recipient.DisappearingState.fromId(disappearingState),
Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(messageVibrateState),
Recipient.VibrateState.fromId(callVibrateState), Recipient.VibrateState.fromId(callVibrateState),
@ -246,6 +259,22 @@ public class RecipientDatabase extends Database {
forceSmsSelection, wrapperHash, blocksCommunityMessageRequests)); forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
} }
public boolean isAutoDownloadFlagSet(Recipient recipient) {
SQLiteDatabase db = getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().serialize() }, null, null, null);
boolean flagUnset = false;
try {
if (cursor.moveToFirst()) {
// flag isn't set if it is -1
flagUnset = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == -1;
}
} finally {
cursor.close();
}
// negate result (is flag set)
return !flagUnset;
}
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(COLOR, color.serialize()); values.put(COLOR, color.serialize());
@ -321,6 +350,21 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public void setAutoDownloadAttachments(@NonNull Recipient recipient, boolean shouldAutoDownloadAttachments) {
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();
try {
ContentValues values = new ContentValues();
values.put(AUTO_DOWNLOAD, shouldAutoDownloadAttachments ? 1 : 0);
db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().serialize()});
recipient.resolve().setAutoDownloadAttachments(shouldAutoDownloadAttachments);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyRecipientListeners();
}
public void setMuted(@NonNull Recipient recipient, long until) { public void setMuted(@NonNull Recipient recipient, long until) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(MUTE_UNTIL, until); values.put(MUTE_UNTIL, until);

View File

@ -3,10 +3,9 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.json.JSONArray import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.AccountId import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@ -14,7 +13,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
companion object { companion object {
private const val sessionContactTable = "session_contact_database" const val sessionContactTable = "session_contact_database"
const val accountID = "session_id" const val accountID = "session_id"
const val name = "name" const val name = "name"
const val nickname = "nickname" const val nickname = "nickname"
@ -83,23 +82,21 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it)) contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it))
} }
contentValues.put(threadID, contact.threadID) contentValues.put(threadID, contact.threadID)
contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID )) database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
notifyConversationListListeners() notifyConversationListListeners()
} }
fun contactFromCursor(cursor: Cursor): Contact { fun contactFromCursor(cursor: Cursor): Contact {
val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID)) val sessionID = cursor.getString(accountID)
val contact = Contact(accountID) val contact = Contact(sessionID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) contact.name = cursor.getStringOrNull(name)
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname)) contact.nickname = cursor.getStringOrNull(nickname)
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL)) contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName)) contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let { cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
contact.profilePictureEncryptionKey = Base64.decode(it) contact.profilePictureEncryptionKey = Base64.decode(it)
} }
contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID)) contact.threadID = cursor.getLong(threadID)
contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0
return contact return contact
} }

View File

@ -8,6 +8,7 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.jobs.MessageSendJob
@ -78,6 +79,13 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
return result.firstOrNull { job -> job.attachmentID == attachmentID } return result.firstOrNull { job -> job.attachmentID == attachmentID }
} }
fun getGroupInviteJob(groupSessionId: String, memberSessionId: String): InviteContactsJob? {
val database = databaseHelper.readableDatabase
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(InviteContactsJob.KEY)) { cursor ->
jobFromCursor(cursor) as? InviteContactsJob
}.firstOrNull { it != null && it.groupSessionId == groupSessionId && it.memberSessionIds.contains(memberSessionId) }
}
fun getMessageSendJob(messageSendJobID: String): MessageSendJob? { fun getMessageSendJob(messageSendJobID: String): MessageSendJob? {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor -> return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor ->

View File

@ -158,7 +158,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id); long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -237,7 +237,7 @@ public class SmsDatabase extends MessagingDatabase {
} }
@Override @Override
public void markAsDeleted(long messageId, boolean read, boolean hasMention) { public void markAsDeleted(long messageId) {
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(); ContentValues contentValues = new ContentValues();
contentValues.put(READ, 1); contentValues.put(READ, 1);
@ -257,7 +257,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id); long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -296,6 +296,11 @@ public class SmsDatabase extends MessagingDatabase {
return isOutgoing; return isOutgoing;
} }
@Override
public String getTypeColumn() {
return TYPE;
}
public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) { public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) {
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();
Cursor cursor = null; Cursor cursor = null;
@ -320,7 +325,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " = ?", ID + " = ?",
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
foundMessage = true; foundMessage = true;
} }
@ -403,7 +408,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
notifyConversationListListeners(); notifyConversationListListeners();
@ -478,7 +483,7 @@ public class SmsDatabase extends MessagingDatabase {
long messageId = db.insert(TABLE_NAME, null, values); long messageId = db.insert(TABLE_NAME, null, values);
if (runThreadUpdate) { if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true);
} }
if (message.getSubscriptionId() != -1) { if (message.getSubscriptionId() != -1) {
@ -570,7 +575,7 @@ public class SmsDatabase extends MessagingDatabase {
} }
if (runThreadUpdate) { if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true);
} }
long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first(); long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first();
if (lastSeen < message.getSentTimestampMillis()) { if (lastSeen < message.getSentTimestampMillis()) {
@ -630,7 +635,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
return threadDeleted; return threadDeleted;
} }
@ -650,7 +655,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " IN (" + StringUtils.join(argsArray, ',') + ")", ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
argValues argValues
); );
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
return threadDeleted; return threadDeleted;
} }
@ -697,15 +702,14 @@ public class SmsDatabase extends MessagingDatabase {
} }
} }
void deleteMessagesInThreadBeforeDate(long threadId, long date) { void deleteMessagesFrom(long threadId, String fromUser) {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = THREAD_ID + " = ? AND (CASE " + TYPE; db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) {
where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date;
} }
where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)"); void deleteMessagesInThreadBeforeDate(long threadId, long date) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = THREAD_ID + " = ? AND " + DATE_SENT + " < " + date;
db.delete(TABLE_NAME, where, new String[] {threadId + ""}); db.delete(TABLE_NAME, where, new String[] {threadId + ""});
} }

View File

@ -17,7 +17,7 @@
*/ */
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX;
import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX; import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
@ -124,9 +124,14 @@ public class ThreadDatabase extends Database {
.map(columnName -> TABLE_NAME + "." + columnName) .map(columnName -> TABLE_NAME + "." + columnName)
.toList(); .toList();
private static final List<String> COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION), private static final List<String> COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION =
// wew
Stream.concat(Stream.concat(Stream.concat(
Stream.of(TYPED_THREAD_PROJECTION),
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)), Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)) Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)),
Stream.of(LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId)
)
.toList(); .toList();
public static String getCreatePinnedCommand() { public static String getCreatePinnedCommand() {
@ -279,9 +284,9 @@ public class ThreadDatabase extends Database {
Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate); Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate, false);
update(threadId, false, true); update(threadId, false);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
} finally { } finally {
@ -293,8 +298,8 @@ public class ThreadDatabase extends Database {
public void trimThreadBefore(long threadId, long timestamp) { public void trimThreadBefore(long threadId, long timestamp) {
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp, false);
update(threadId, false, true); update(threadId, false);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -428,32 +433,6 @@ public class ThreadDatabase extends Database {
return db.rawQuery(query, null); return db.rawQuery(query, null);
} }
public int getUnapprovedConversationCount() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try {
String query = "SELECT COUNT (*) FROM " + TABLE_NAME +
" LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
cursor = db.rawQuery(query, null);
if (cursor != null && cursor.moveToFirst())
return cursor.getInt(0);
} finally {
if (cursor != null)
cursor.close();
}
return 0;
}
public long getLatestUnapprovedConversationTimestamp() { public long getLatestUnapprovedConversationTimestamp() {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null; Cursor cursor = null;
@ -492,13 +471,15 @@ public class ThreadDatabase extends Database {
} }
public Cursor getApprovedConversationList() { public Cursor getApprovedConversationList() {
String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+ LEGACY_CLOSED_GROUP_PREFIX +"%') " +
"OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 "; "AND " + ARCHIVED + " = 0 ";
return getConversationList(where); return getConversationList(where);
} }
public Cursor getUnapprovedConversationList() { public Cursor getUnapprovedConversationList() {
String where = MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + String where = "("+MESSAGE_COUNT + " != 0 OR "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" LIKE '"+IdPrefix.GROUP.getValue()+"%')" +
" AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
@ -722,19 +703,14 @@ public class ThreadDatabase extends Database {
notifyConversationListListeners(); notifyConversationListListeners();
} }
public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) { public boolean update(long threadId, boolean unarchive) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId); long count = mmsSmsDatabase.getConversationCount(threadId);
boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId); MmsSmsDatabase.Reader reader = null;
if (count == 0 && shouldDeleteEmptyThread) { try {
deleteThread(threadId); reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId));
notifyConversationListListeners();
return true;
}
try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) {
MessageRecord record = null; MessageRecord record = null;
if (reader != null) { if (reader != null) {
record = reader.getNext(); record = reader.getNext();
@ -748,11 +724,7 @@ public class ThreadDatabase extends Database {
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
return false; return false;
} else { } else {
if (shouldDeleteEmptyThread) { updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0);
deleteThread(threadId);
return true;
}
// todo: add empty snippet that clears existing data
return false; return false;
} }
} finally { } finally {
@ -800,9 +772,8 @@ public class ThreadDatabase extends Database {
return setLastSeen(threadId, lastSeenTime); return setLastSeen(threadId, lastSeenTime);
} }
private boolean possibleToDeleteThreadOnEmpty(long threadId) { private boolean deleteThreadOnEmpty(long threadId) {
Recipient threadRecipient = getRecipientForThreadId(threadId); return false;
return threadRecipient != null && !threadRecipient.isCommunityRecipient();
} }
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
@ -844,6 +815,8 @@ public class ThreadDatabase extends Database {
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" LEFT OUTER JOIN " + LokiMessageDatabase.groupInviteTable +
" ON "+ TABLE_NAME + "." + ID + " = " + LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId +
" WHERE " + where + " WHERE " + where +
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC"; " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
@ -923,6 +896,7 @@ public class ThreadDatabase extends Database {
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
Uri snippetUri = getSnippetUri(cursor); Uri snippetUri = getSnippetUri(cursor);
boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0; boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0;
String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId));
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0; readReceiptCount = 0;
@ -940,7 +914,7 @@ public class ThreadDatabase extends Database {
return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count, return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type, unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned); distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned, invitingAdmin);
} }
private @Nullable Uri getSnippetUri(Cursor cursor) { private @Nullable Uri getSnippetUri(Cursor cursor) {

View File

@ -90,9 +90,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV44 = 65; private static final int lokiV44 = 65;
private static final int lokiV45 = 66; private static final int lokiV45 = 66;
private static final int lokiV46 = 67; private static final int lokiV46 = 67;
private static final int lokiV47 = 68;
private static final int lokiV48 = 69;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV46; private static final int DATABASE_VERSION = lokiV48;
private static final int MIN_DATABASE_VERSION = lokiV7; private static final int MIN_DATABASE_VERSION = lokiV7;
private static final String CIPHER3_DATABASE_NAME = "signal.db"; private static final String CIPHER3_DATABASE_NAME = "signal.db";
public static final String DATABASE_NAME = "signal_v4.db"; public static final String DATABASE_NAME = "signal_v4.db";
@ -362,6 +364,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddWrapperHash());
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand());
db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger());
} }
@Override @Override
@ -628,6 +635,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
} }
if (oldVersion < lokiV47) {
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
}
if (oldVersion < lokiV48) {
db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand());
db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger());
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -118,7 +118,7 @@ public abstract class MessageRecord extends DisplayRecord {
public SpannableString getDisplayBody(@NonNull Context context) { public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) { if (isGroupUpdateMessage()) {
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody()); UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody());
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing(), true));
} else if (isExpirationTimerUpdate()) { } else if (isExpirationTimerUpdate()) {
int seconds = (int) (getExpiresIn() / 1000); int seconds = (int) (getExpiresIn() / 1000);
boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient(); boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();

View File

@ -30,6 +30,9 @@ import android.text.TextUtils;
import android.text.style.StyleSpan; import android.text.style.StyleSpan;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.session.libsession.messaging.utilities.UpdateMessageBuilder;
import org.session.libsession.messaging.utilities.UpdateMessageData;
import com.squareup.phrase.Phrase; import com.squareup.phrase.Phrase;
import org.session.libsession.utilities.ExpirationUtil; import org.session.libsession.utilities.ExpirationUtil;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
@ -57,13 +60,14 @@ public class ThreadRecord extends DisplayRecord {
private final long lastSeen; private final long lastSeen;
private final boolean pinned; private final boolean pinned;
private final int initialRecipientHash; private final int initialRecipientHash;
private final String invitingAdminId;
private final long dateSent; private final long dateSent;
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
@Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
long snippetType, int distributionType, boolean archived, long expiresIn, long snippetType, int distributionType, boolean archived, long expiresIn,
long lastSeen, int readReceiptCount, boolean pinned) long lastSeen, int readReceiptCount, boolean pinned, String invitingAdminId)
{ {
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
this.snippetUri = snippetUri; this.snippetUri = snippetUri;
@ -77,6 +81,7 @@ public class ThreadRecord extends DisplayRecord {
this.lastSeen = lastSeen; this.lastSeen = lastSeen;
this.pinned = pinned; this.pinned = pinned;
this.initialRecipientHash = recipient.hashCode(); this.initialRecipientHash = recipient.hashCode();
this.invitingAdminId = invitingAdminId;
this.dateSent = date; this.dateSent = date;
} }
@ -115,6 +120,18 @@ public class ThreadRecord extends DisplayRecord {
@Override @Override
public SpannableString getDisplayBody(@NonNull Context context) { public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) { if (isGroupUpdateMessage()) {
String body = getBody();
if (!body.isEmpty()) {
UpdateMessageData updateMessageData = UpdateMessageData.fromJSON(body);
if (updateMessageData != null) {
return emphasisAdded(
UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false)
.toString()
);
} else {
return null;
}
}
return emphasisAdded(context.getString(R.string.groupUpdated)); return emphasisAdded(context.getString(R.string.groupUpdated));
} else if (isOpenGroupInvitation()) { } else if (isOpenGroupInvitation()) {
return emphasisAdded(context.getString(R.string.communityInvitation)); return emphasisAdded(context.getString(R.string.communityInvitation));
@ -221,4 +238,30 @@ public class ThreadRecord extends DisplayRecord {
public boolean isPinned() { return pinned; } public boolean isPinned() { return pinned; }
public int getInitialRecipientHash() { return initialRecipientHash; } public int getInitialRecipientHash() { return initialRecipientHash; }
public boolean isLeavingGroup() {
if (isGroupUpdateMessage()) {
String body = getBody();
if (!body.isEmpty()) {
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body);
return updateMessageData.isGroupLeavingKind();
}
}
return false;
}
public boolean isErrorLeavingGroup() {
if (isGroupUpdateMessage()) {
String body = getBody();
if (!body.isEmpty()) {
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body);
return updateMessageData.isGroupErrorQuitKind();
}
}
return false;
}
public String getInvitingAdminId() {
return invitingAdminId;
}
} }

View File

@ -76,7 +76,7 @@ class DebugMenuViewModel @Inject constructor(
// clear remote and local data, then restart the app // clear remote and local data, then restart the app
viewModelScope.launch { viewModelScope.launch {
try { try {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get() ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application)
} catch (e: Exception) { } catch (e: Exception) {
// we can ignore fails here as we might be switching environments before the user gets a public key // we can ignore fails here as we might be switching environments before the user gets a public key
} }

View File

@ -1,14 +1,20 @@
package org.thoughtcrime.securesms.dependencies package org.thoughtcrime.securesms.dependencies
import android.content.Context
import android.widget.Toast
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.Toaster
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -21,6 +27,17 @@ abstract class AppModule {
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
} }
@Module
@InstallIn(SingletonComponent::class)
class ToasterModule {
@Provides
@Singleton
fun provideToaster(@ApplicationContext context: Context) = Toaster { stringRes, toastLength, parameters ->
val string = context.getString(stringRes, parameters)
Toast.makeText(context, string, toastLength).show()
}
}
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface AppComponent { interface AppComponent {

View File

@ -1,16 +1,12 @@
package org.thoughtcrime.securesms.dependencies package org.thoughtcrime.securesms.dependencies
import android.content.Context import android.content.Context
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ServiceComponent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ServiceScoped
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.CallDataProvider import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.CallManager
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
import javax.inject.Singleton import javax.inject.Singleton
@ -25,7 +21,7 @@ object CallModule {
@Provides @Provides
@Singleton @Singleton
fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) = fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: StorageProtocol) =
CallManager(context, audioManagerCompat, storage) CallManager(context, audioManagerCompat, storage)
} }

View File

@ -2,16 +2,27 @@ package org.thoughtcrime.securesms.dependencies
import android.content.Context import android.content.Context
import android.os.Trace import android.os.Trace
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import network.loki.messenger.libsession_util.Config
import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig 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.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.UserProfile
import network.loki.messenger.libsession_util.util.Sodium
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.GroupManager
@ -20,6 +31,7 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ConfigFactory( class ConfigFactory(
private val context: Context, private val context: Context,
private val configDatabase: ConfigDatabase, private val configDatabase: ConfigDatabase,
/** <ed25519 secret key,33 byte prefixed public key (hex encoded)> */
private val maybeGetUserInfo: () -> Pair<ByteArray, String>? private val maybeGetUserInfo: () -> Pair<ByteArray, String>?
) : ) :
ConfigFactoryProtocol { ConfigFactoryProtocol {
@ -28,10 +40,10 @@ class ConfigFactory(
// config change, any message which would normally result in a config change which was sent // config change, any message which would normally result in a config change which was sent
// before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have // before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have
// it's changes applied (control text will still be added though) // it's changes applied (control text will still be added though)
val configChangeBufferPeriod: Long = (2 * 60 * 1000) const val configChangeBufferPeriod: Long = (2 * 60 * 1000)
} }
fun keyPairChanged() { // this should only happen restoring or clearing data fun keyPairChanged() { // this should only happen restoring or clearing datac
_userConfig?.free() _userConfig?.free()
_contacts?.free() _contacts?.free()
_convoVolatileConfig?.free() _convoVolatileConfig?.free()
@ -52,6 +64,13 @@ class ConfigFactory(
private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) } private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf() private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf()
private val _configUpdateNotifications = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val configUpdateNotifications get() = _configUpdateNotifications
fun registerListener(listener: ConfigFactoryUpdateListener) { fun registerListener(listener: ConfigFactoryUpdateListener) {
listeners += listener listeners += listener
} }
@ -146,6 +165,101 @@ class ConfigFactory(
_userGroups _userGroups
} }
private fun getGroupInfo(groupSessionId: AccountId) = userGroups?.getClosedGroup(groupSessionId.hexString)
override fun getGroupInfoConfig(groupSessionId: AccountId): GroupInfoConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
// get any potential initial dumps
val dump = configDatabase.retrieveConfigAndHashes(
ConfigDatabase.INFO_VARIANT,
groupSessionId.hexString
) ?: byteArrayOf()
GroupInfoConfig.newInstance(groupSessionId.pubKeyBytes, groupInfo.adminKey, dump)
}
override fun getGroupKeysConfig(groupSessionId: AccountId,
info: GroupInfoConfig?,
members: GroupMembersConfig?,
free: Boolean): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
// Get the user info or return early
val (userSk, _) = maybeGetUserInfo() ?: return@let null
// Get the group info or return early
val usedInfo = info ?: getGroupInfoConfig(groupSessionId) ?: return@let null
// Get the group members or return early
val usedMembers = members ?: getGroupMemberConfig(groupSessionId) ?: return@let null
// Get the dump or empty
val dump = configDatabase.retrieveConfigAndHashes(
ConfigDatabase.KEYS_VARIANT,
groupSessionId.hexString
) ?: byteArrayOf()
// Put it all together
val keys = GroupKeysConfig.newInstance(
userSk,
groupSessionId.pubKeyBytes,
groupInfo.adminKey,
dump,
usedInfo,
usedMembers
)
if (free) {
info?.free()
members?.free()
}
if (usedInfo !== info) usedInfo.free()
if (usedMembers !== members) usedMembers.free()
keys
}
override fun getGroupMemberConfig(groupSessionId: AccountId): GroupMembersConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
// Get initial dump if we have one
val dump = configDatabase.retrieveConfigAndHashes(
ConfigDatabase.MEMBER_VARIANT,
groupSessionId.hexString
) ?: byteArrayOf()
GroupMembersConfig.newInstance(
groupSessionId.pubKeyBytes,
groupInfo.adminKey,
dump
)
}
override fun constructGroupKeysConfig(
groupSessionId: AccountId,
info: GroupInfoConfig,
members: GroupMembersConfig
): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
val (userSk, _) = maybeGetUserInfo() ?: return null
GroupKeysConfig.newInstance(
userSk,
groupSessionId.pubKeyBytes,
groupInfo.adminKey,
info = info,
members = members
)
}
override fun userSessionId(): AccountId? {
return maybeGetUserInfo()?.second?.let(::AccountId)
}
override fun maybeDecryptForUser(encoded: ByteArray, domain: String, closedGroupSessionId: AccountId): ByteArray? {
val secret = maybeGetUserInfo()?.first ?: run {
Log.e("ConfigFactory", "No user ed25519 secret key decrypting a message for us")
return null
}
return Sodium.decryptForMultipleSimple(
encoded = encoded,
ed25519SecretKey = secret,
domain = domain,
senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes)
)
}
override fun getUserConfigs(): List<ConfigBase> = override fun getUserConfigs(): List<ConfigBase> =
listOfNotNull(user, contacts, convoVolatile, userGroups) listOfNotNull(user, contacts, convoVolatile, userGroups)
@ -153,13 +267,23 @@ class ConfigFactory(
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) { private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
val dumped = user?.dump() ?: return val dumped = user?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp) configDatabase.storeConfig(
SharedConfigMessage.Kind.USER_PROFILE.name,
publicKey,
dumped,
timestamp
)
} }
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) { private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
val dumped = contacts?.dump() ?: return val dumped = contacts?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp) configDatabase.storeConfig(
SharedConfigMessage.Kind.CONTACTS.name,
publicKey,
dumped,
timestamp
)
} }
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) { private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
@ -176,21 +300,52 @@ class ConfigFactory(
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) { private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
val dumped = userGroups?.dump() ?: return val dumped = userGroups?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp) configDatabase.storeConfig(
SharedConfigMessage.Kind.GROUPS.name,
publicKey,
dumped,
timestamp
)
} }
override fun persist(forConfigObject: ConfigBase, timestamp: Long) { fun persistGroupConfigDump(forConfigObject: ConfigBase, groupSessionId: AccountId, timestamp: Long) = synchronized(userGroupsLock) {
val dumped = forConfigObject.dump()
val variant = when (forConfigObject) {
is GroupMembersConfig -> ConfigDatabase.MEMBER_VARIANT
is GroupInfoConfig -> ConfigDatabase.INFO_VARIANT
else -> throw Exception("Shouldn't be called")
}
configDatabase.storeConfig(
variant,
groupSessionId.hexString,
dumped,
timestamp
)
_configUpdateNotifications.tryEmit(Unit)
}
override fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String?) {
try { try {
if (forConfigObject is ConfigBase && !forConfigObject.needsDump() || forConfigObject is GroupKeysConfig && !forConfigObject.needsDump()) {
Log.d("ConfigFactory", "Don't need to persist ${forConfigObject.javaClass} for $forPublicKey pubkey")
return
}
listeners.forEach { listener -> listeners.forEach { listener ->
listener.notifyUpdates(forConfigObject, timestamp) listener.notifyUpdates(forConfigObject, timestamp)
} }
when (forConfigObject) { when (forConfigObject) {
is UserProfile -> persistUserConfigDump(timestamp) is UserProfile -> persistUserConfigDump(timestamp)
is Contacts -> persistContactsConfigDump(timestamp) is Contacts -> persistContactsConfigDump(timestamp)
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp) is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp) is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
is GroupMembersConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp)
is GroupInfoConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp)
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet") else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
} }
_configUpdateNotifications.tryEmit(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e) Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
} }
@ -207,23 +362,25 @@ class ConfigFactory(
if (openGroupId != null) { if (openGroupId != null) {
val userGroups = userGroups ?: return false val userGroups = userGroups ?: return false
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context) val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false val openGroup =
get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
// Not handling the `hidden` behaviour for communities so just indicate the existence // Not handling the `hidden` behaviour for communities so just indicate the existence
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null) return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
} } else if (groupPublicKey != null) {
else if (groupPublicKey != null) {
val userGroups = userGroups ?: return false val userGroups = userGroups ?: return false
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence // Not handling the `hidden` behaviour for legacy groups so just indicate the existence
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null) return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
userGroups.getClosedGroup(groupPublicKey) != null
} else {
userGroups.getLegacyGroupInfo(groupPublicKey) != null
} }
else if (publicKey == userPublicKey) { } else if (publicKey == userPublicKey) {
val user = user ?: return false val user = user ?: return false
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN) return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
} } else if (publicKey != null) {
else if (publicKey != null) {
val contacts = contacts ?: return false val contacts = contacts ?: return false
val targetContact = contacts.get(publicKey) ?: return false val targetContact = contacts.get(publicKey) ?: return false
@ -233,10 +390,44 @@ class ConfigFactory(
return false return false
} }
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { override fun canPerformChange(
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) variant: String,
publicKey: String,
changeTimestampMs: Long
): Boolean {
val lastUpdateTimestampMs =
configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
// Ensure the change occurred after the last config message was handled (minus the buffer period) // Ensure the change occurred after the last config message was handled (minus the buffer period)
return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod)) return (changeTimestampMs >= (lastUpdateTimestampMs - configChangeBufferPeriod))
}
override fun saveGroupConfigs(
groupKeys: GroupKeysConfig,
groupInfo: GroupInfoConfig,
groupMembers: GroupMembersConfig
) {
val pubKey = groupInfo.id().hexString
val timestamp = SnodeAPI.nowWithOffset
// this would be nicer with a .any iteration or something but the base types don't line up
val anyNeedDump = groupKeys.needsDump() || groupInfo.needsDump() || groupMembers.needsDump()
if (!anyNeedDump) return Log.d("ConfigFactory", "Group config doesn't need dump, skipping")
else Log.d("ConfigFactory", "Group config needs dump, storing and notifying")
configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp)
_configUpdateNotifications.tryEmit(Unit)
}
override fun removeGroup(closedGroupId: AccountId) {
val groups = userGroups ?: return
groups.eraseClosedGroup(closedGroupId.hexString)
persist(groups, SnodeAPI.nowWithOffset)
configDatabase.deleteGroupConfigs(closedGroupId)
}
override fun scheduleUpdate(destination: Destination) {
// there's probably a better way to do this
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination)
} }
} }

View File

@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.dependencies
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.database.Storage
@Module
@InstallIn(SingletonComponent::class)
abstract class DatabaseBindings {
@Binds
abstract fun bindStorageProtocol(storage: Storage): StorageProtocol
}

View File

@ -5,6 +5,7 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.*
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase

View File

@ -141,8 +141,13 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { fun provideStorage(@ApplicationContext context: Context,
val storage = Storage(context,openHelper, configFactory) openHelper: SQLCipherOpenHelper,
configFactory: ConfigFactory,
threadDatabase: ThreadDatabase,
pollerFactory: PollerFactory,
): Storage {
val storage = Storage(context, openHelper, configFactory, pollerFactory)
threadDatabase.setUpdateListener(storage) threadDatabase.setUpdateListener(storage)
return storage return storage
} }

View File

@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.dependencies
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.plus
import network.loki.messenger.libsession_util.util.GroupInfo
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller
import org.session.libsignal.utilities.AccountId
import java.util.concurrent.ConcurrentHashMap
class PollerFactory(private val scope: CoroutineScope,
private val executor: CoroutineDispatcher,
private val configFactory: ConfigFactory) {
private val pollers = ConcurrentHashMap<AccountId, ClosedGroupPoller>()
fun pollerFor(sessionId: AccountId): ClosedGroupPoller? {
// Check if the group is currently in our config and approved, don't start if it isn't
if (configFactory.userGroups?.getClosedGroup(sessionId.hexString)?.invited != false) return null
return pollers.getOrPut(sessionId) {
ClosedGroupPoller(scope + SupervisorJob(), executor, sessionId, configFactory)
}
}
fun startAll() {
configFactory.userGroups?.allClosedGroupInfo()?.filterNot(GroupInfo.ClosedGroupInfo::invited)?.forEach {
pollerFor(it.groupAccountId)?.start()
}
}
fun stopAll() {
pollers.forEach { (_, poller) ->
poller.stop()
}
}
fun updatePollers() {
val currentGroups = configFactory.userGroups?.allClosedGroupInfo()?.filterNot(GroupInfo.ClosedGroupInfo::invited) ?: return
val toRemove = pollers.filter { (id, _) -> id !in currentGroups.map { it.groupAccountId } }
toRemove.forEach { (id, _) ->
pollers.remove(id)?.stop()
}
startAll()
}
}

View File

@ -6,16 +6,24 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.ConfigDatabase
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object SessionUtilModule { object SessionUtilModule {
const val POLLER_SCOPE = "poller_coroutine_scope"
private fun maybeUserEdSecretKey(context: Context): ByteArray? { private fun maybeUserEdSecretKey(context: Context): ByteArray? {
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
return edKey.secretKey.asBytes return edKey.secretKey.asBytes
@ -33,4 +41,19 @@ object SessionUtilModule {
registerListener(context as ConfigFactoryUpdateListener) registerListener(context as ConfigFactoryUpdateListener)
} }
@Provides
@Named(POLLER_SCOPE)
fun providePollerScope(@ApplicationContext applicationContext: Context): CoroutineScope = GlobalScope
@OptIn(ExperimentalCoroutinesApi::class)
@Provides
@Named(POLLER_SCOPE)
fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
@Provides
@Singleton
fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope,
@Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher,
configFactory: ConfigFactory) = PollerFactory(coroutineScope, dispatcher, configFactory)
} }

View File

@ -4,7 +4,7 @@ import android.content.Context
import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 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.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
@ -25,7 +25,7 @@ object ClosedGroupManager {
// Notify the PN server // Notify the PN server
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey) PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
// Stop polling // Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId) storage.cancelPendingMessageSendJobs(threadId)
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
if (delete) { if (delete) {
@ -33,16 +33,9 @@ object ClosedGroupManager {
} }
} }
fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean {
val groups = userGroups ?: return false
if (!group.isClosedGroup) return false
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
return groups.eraseLegacyGroup(groupPublicKey)
}
fun ConfigFactory.updateLegacyGroup(group: GroupRecord) { fun ConfigFactory.updateLegacyGroup(group: GroupRecord) {
val groups = userGroups ?: return val groups = userGroups ?: return
if (!group.isClosedGroup) return if (!group.isLegacyClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val threadId = storage.getThreadId(group.encodedId) ?: return val threadId = storage.getThreadId(group.encodedId) ?: return
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())

View File

@ -1,127 +1,44 @@
package org.thoughtcrime.securesms.groups package org.thoughtcrime.securesms.groups
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import androidx.compose.ui.platform.ComposeView
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate
import network.loki.messenger.databinding.FragmentCreateGroupBinding
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
import javax.inject.Inject
@AndroidEntryPoint
class CreateGroupFragment : Fragment() { class CreateGroupFragment : Fragment() {
@Inject
lateinit var device: Device
private lateinit var binding: FragmentCreateGroupBinding
private val viewModel: CreateGroupViewModel by viewModels()
lateinit var delegate: StartConversationDelegate
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
binding = FragmentCreateGroupBinding.inflate(inflater) return ComposeView(requireContext()).apply {
return binding.root val delegate = (parentFragment as? StartConversationDelegate)
} ?: (activity as? StartConversationDelegate)
?: NullStartConversationDelegate
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { setContent {
super.onViewCreated(view, savedInstanceState) SessionMaterialTheme {
val adapter = SelectContactsAdapter(requireContext(), Glide.with(requireContext())) CreateGroupScreen(
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } onNavigateToConversationScreen = { threadID ->
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } startActivity(
binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks { Intent(requireContext(), ConversationActivityV2::class.java)
override fun onQueryChanged(query: String) { .putExtra(ConversationActivityV2.THREAD_ID, threadID)
adapter.members = viewModel.filter(query).map { it.address.serialize() } )
} },
} onBack = delegate::onDialogBackPressed,
binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() } onClose = delegate::onDialogClosePressed
binding.recyclerView.adapter = adapter
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
setDrawable(it)
}
}
binding.recyclerView.addItemDecoration(divider)
var isLoading = false
binding.createClosedGroupButton.setOnClickListener {
if (isLoading) return@setOnClickListener
val name = binding.nameEditText.text.trim()
if (name.isEmpty()) {
return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterPlease, Toast.LENGTH_LONG).show()
}
// Limit the group name length if it exceeds the limit
if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) {
return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterShorter, Toast.LENGTH_LONG).show()
}
val selectedMembers = adapter.selectedMembers
if (selectedMembers.isEmpty()) {
return@setOnClickListener Toast.makeText(context, R.string.groupCreateErrorNoMembers, Toast.LENGTH_LONG).show()
}
if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later
return@setOnClickListener Toast.makeText(context, R.string.groupAddMemberMaximum, Toast.LENGTH_LONG).show()
}
val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!!
isLoading = true
binding.loaderContainer.fadeIn()
MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
binding.loaderContainer.fadeOut()
isLoading = false
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false))
openConversationActivity(
requireContext(),
threadID,
Recipient.from(requireContext(), Address.fromSerialized(groupID), false)
) )
delegate.onDialogClosePressed()
}.failUi {
binding.loaderContainer.fadeOut()
isLoading = false
Toast.makeText(context, it.message, Toast.LENGTH_LONG).show()
} }
} }
binding.mainContentGroup.isVisible = !viewModel.recipients.value.isNullOrEmpty() }
binding.emptyStateGroup.isVisible = viewModel.recipients.value.isNullOrEmpty()
viewModel.recipients.observe(viewLifecycleOwner) { recipients ->
adapter.members = recipients.map { it.address.serialize() }
} }
} }
private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) {
val intent = Intent(context, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address)
context.startActivity(intent)
}
}

View File

@ -1,46 +1,93 @@
package org.thoughtcrime.securesms.groups package org.thoughtcrime.securesms.groups
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CreateGroupViewModel @Inject constructor( class CreateGroupViewModel @Inject constructor(
private val threadDb: ThreadDatabase, configFactory: ConfigFactory,
private val textSecurePreferences: TextSecurePreferences private val storage: StorageProtocol,
): ViewModel() { ): ViewModel() {
// Child view model to handle contact selection logic
val selectContactsViewModel = SelectContactsViewModel(
storage = storage,
configFactory = configFactory,
excludingAccountIDs = emptySet(),
scope = viewModelScope,
)
private val _recipients = MutableLiveData<List<Recipient>>() // Input: group name
val recipients: LiveData<List<Recipient>> = _recipients private val mutableGroupName = MutableStateFlow("")
private val mutableGroupNameError = MutableStateFlow("")
init { // Output: group name
val groupName: StateFlow<String> get() = mutableGroupName
val groupNameError: StateFlow<String> get() = mutableGroupNameError
// Output: loading state
private val mutableIsLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> get() = mutableIsLoading
// Events
private val mutableEvents = MutableSharedFlow<CreateGroupEvent>()
val events: SharedFlow<CreateGroupEvent> get() = mutableEvents
fun onCreateClicked() {
viewModelScope.launch { viewModelScope.launch {
threadDb.approvedConversationList.use { openCursor -> val groupName = groupName.value.trim()
val reader = threadDb.readerFor(openCursor) if (groupName.isBlank()) {
val recipients = mutableListOf<Recipient>() mutableGroupNameError.value = "Group name cannot be empty"
while (true) { return@launch
recipients += reader.next?.recipient ?: break
} }
withContext(Dispatchers.Main) {
_recipients.value = recipients val selected = selectContactsViewModel.currentSelected
.filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() } if (selected.isEmpty()) {
mutableEvents.emit(CreateGroupEvent.Error("Please select at least one contact"))
return@launch
} }
mutableIsLoading.value = true
val recipient = withContext(Dispatchers.Default) {
storage.createNewGroup(groupName, "", selected)
} }
if (recipient.isPresent) {
val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.get().address) }
mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId))
} else {
mutableEvents.emit(CreateGroupEvent.Error("Failed to create group"))
}
mutableIsLoading.value = false
} }
} }
fun filter(query: String): List<Recipient> { fun onGroupNameChanged(name: String) {
return _recipients.value?.filter { mutableGroupName.value = if (name.length > MAX_GROUP_NAME_LENGTH) {
it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true name.substring(0, MAX_GROUP_NAME_LENGTH)
} ?: emptyList() } else {
name
}
mutableGroupNameError.value = ""
} }
} }
sealed interface CreateGroupEvent {
data class NavigateToConversation(val threadID: Long): CreateGroupEvent
data class Error(val message: String): CreateGroupEvent
}

View File

@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.groups
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.groups.compose.EditGroupScreen
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
@AndroidEntryPoint
class EditGroupActivity: PassphraseRequiredActionBarActivity() {
companion object {
private const val EXTRA_GROUP_ID = "EditClosedGroupActivity_groupID"
fun createIntent(context: Context, groupSessionId: String): Intent {
return Intent(context, EditGroupActivity::class.java).apply {
putExtra(EXTRA_GROUP_ID, groupSessionId)
}
}
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContent {
SessionMaterialTheme {
EditGroupScreen(
groupSessionId = intent.getStringExtra(EXTRA_GROUP_ID)!!,
onFinish = this::finish
)
}
}
}
}

View File

@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.groups
import androidx.lifecycle.ViewModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact
@HiltViewModel(assistedFactory = EditGroupInviteViewModel.Factory::class)
class EditGroupInviteViewModel @AssistedInject constructor(
@Assisted private val groupSessionId: String,
private val storage: StorageProtocol
): ViewModel() {
@AssistedFactory
interface Factory {
fun create(groupSessionId: String): EditGroupInviteViewModel
}
}
data class EditGroupInviteState(
val viewState: EditGroupInviteViewState,
)
data class EditGroupInviteViewState(
val currentMembers: List<GroupMemberState>,
val allContacts: Set<Contact>
)

View File

@ -0,0 +1,260 @@
package org.thoughtcrime.securesms.groups
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import network.loki.messenger.libsession_util.util.GroupMember
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.dependencies.ConfigFactory
const val MAX_GROUP_NAME_LENGTH = 100
@HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class)
class EditGroupViewModel @AssistedInject constructor(
@Assisted private val groupSessionId: String,
private val storage: StorageProtocol,
configFactory: ConfigFactory
) : ViewModel() {
// Input/Output state
private val mutableEditingName = MutableStateFlow<String?>(null)
// Output: The name of the group being edited. Null if it's not in edit mode, not to be confused
// with empty string, where it's a valid editing state.
val editingName: StateFlow<String?> get() = mutableEditingName
// Output: the source-of-truth group information. Other states are derived from this.
private val groupInfo: StateFlow<Pair<GroupDisplayInfo, List<GroupMemberState>>?> =
configFactory.configUpdateNotifications
.onStart { emit(Unit) }
.map {
withContext(Dispatchers.Default) {
val currentUserId = checkNotNull(storage.getUserPublicKey()) {
"User public key is null"
}
val displayInfo = storage.getClosedGroupDisplayInfo(groupSessionId)
?: return@withContext null
val members = storage.getMembers(groupSessionId)
.asSequence()
.filter { !it.removed }
.mapTo(mutableListOf()) { member ->
createGroupMember(
member = member,
myAccountId = currentUserId,
amIAdmin = displayInfo.isUserAdmin,
)
}
sortMembers(members, currentUserId)
displayInfo to members
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
// Output: whether the group name can be edited. This is true if the group is loaded successfully.
val canEditGroupName: StateFlow<Boolean> = groupInfo
.map { it != null }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
// Output: The name of the group. This is the current name of the group, not the name being edited.
val groupName: StateFlow<String> = groupInfo
.map { it?.first?.name.orEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
// Output: the list of the members and their state in the group.
val members: StateFlow<List<GroupMemberState>> = groupInfo
.map { it?.second.orEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
// Output: whether we should show the "add members" button
val showAddMembers: StateFlow<Boolean> = groupInfo
.map { it?.first?.isUserAdmin == true }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
// Output: Intermediate states
private val mutableInProgress = MutableStateFlow(false)
val inProgress: StateFlow<Boolean> get() = mutableInProgress
// Output: errors
private val mutableError = MutableStateFlow<String?>(null)
val error: StateFlow<String?> get() = mutableError
// Output:
val excludingAccountIDsFromContactSelection: Set<String>
get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId }.orEmpty()
private fun createGroupMember(
member: GroupMember,
myAccountId: String,
amIAdmin: Boolean,
): GroupMemberState {
var status = ""
var highlightStatus = false
var name = member.name.orEmpty()
when {
member.sessionId == myAccountId -> {
name = "You"
}
member.promotionPending -> {
status = "Promotion sent"
}
member.invitePending -> {
status = "Invite Sent"
}
member.inviteFailed -> {
status = "Invite Failed"
highlightStatus = true
}
member.promotionFailed -> {
status = "Promotion Failed"
highlightStatus = true
}
}
return GroupMemberState(
accountId = member.sessionId,
name = name,
canRemove = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted,
canPromote = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted,
canResendPromotion = amIAdmin && member.sessionId != myAccountId && member.promotionFailed,
canResendInvite = amIAdmin && member.sessionId != myAccountId && member.inviteFailed,
status = status,
highlightStatus = highlightStatus
)
}
private fun sortMembers(members: MutableList<GroupMemberState>, currentUserId: String) {
// Order or members:
// 1. Current user always comes first
// 2. Then sort by name
// 3. Then sort by account ID
members.sortWith(
compareBy(
{ it.accountId != currentUserId },
{ it.name },
{ it.accountId }
)
)
}
fun onContactSelected(contacts: Set<Contact>) {
viewModelScope.launch(Dispatchers.Default) {
storage.inviteClosedGroupMembers(groupSessionId, contacts.map { it.accountID })
}
}
fun onResendInviteClicked(contactSessionId: String) {
viewModelScope.launch(Dispatchers.Default) {
JobQueue.shared.add(InviteContactsJob(groupSessionId, arrayOf(contactSessionId)))
}
}
fun onPromoteContact(memberSessionId: String) {
viewModelScope.launch(Dispatchers.Default) {
storage.promoteMember(AccountId(groupSessionId), listOf(AccountId(memberSessionId)))
}
}
fun onRemoveContact(contactSessionId: String, removeMessages: Boolean) {
viewModelScope.launch {
mutableInProgress.value = true
// We need to use GlobalScope here because we don't want
// "removeMember" to be cancelled when the view model is cleared. This operation
// is expected to complete even if the view model is cleared.
val task = GlobalScope.launch {
storage.removeMember(
groupAccountId = AccountId(groupSessionId),
removedMembers = listOf(AccountId(contactSessionId)),
removeMessages = removeMessages
)
}
try {
task.join()
} catch (e: Exception) {
mutableError.value = e.localizedMessage.orEmpty()
} finally {
mutableInProgress.value = false
}
}
}
fun onResendPromotionClicked(memberSessionId: String) {
onPromoteContact(memberSessionId)
}
fun onEditNameClicked() {
mutableEditingName.value = groupInfo.value?.first?.name.orEmpty()
}
fun onCancelEditingNameClicked() {
mutableEditingName.value = null
}
fun onEditingNameChanged(value: String) {
// Cut off the group name so we don't exceed max length
if (value.length > MAX_GROUP_NAME_LENGTH) {
mutableEditingName.value = value.substring(0, MAX_GROUP_NAME_LENGTH)
} else {
mutableEditingName.value = value
}
}
fun onEditNameConfirmClicked() {
val newName = mutableEditingName.value
if (newName != null) {
storage.setName(groupSessionId, newName.trim())
mutableEditingName.value = null
}
}
fun onDismissError() {
mutableError.value = null
}
@AssistedFactory
interface Factory {
fun create(groupSessionId: String): EditGroupViewModel
}
}
data class GroupMemberState(
val accountId: String,
val name: String,
val status: String,
val highlightStatus: Boolean,
val canResendInvite: Boolean,
val canResendPromotion: Boolean,
val canRemove: Boolean,
val canPromote: Boolean,
) {
val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion
}

View File

@ -4,13 +4,13 @@ import android.content.Context
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.AsyncLoader import org.thoughtcrime.securesms.util.AsyncLoader
class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditClosedGroupActivity.GroupMembers>(context) { class EditLegacyClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditLegacyGroupActivity.GroupMembers>(context) {
override fun loadInBackground(): EditClosedGroupActivity.GroupMembers { override fun loadInBackground(): EditLegacyGroupActivity.GroupMembers {
val groupDatabase = DatabaseComponent.get(context).groupDatabase() val groupDatabase = DatabaseComponent.get(context).groupDatabase()
val members = groupDatabase.getGroupMembers(groupID, true) val members = groupDatabase.getGroupMembers(groupID, true)
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID) val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
return EditClosedGroupActivity.GroupMembers( return EditLegacyGroupActivity.GroupMembers(
members.map { members.map {
it.address.toString() it.address.toString()
}, },

View File

@ -18,15 +18,12 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.task
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.groupSizeLimit import org.session.libsession.messaging.sending_receiving.groupSizeLimit
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
@ -43,12 +40,11 @@ import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut import org.thoughtcrime.securesms.util.fadeOut
@AndroidEntryPoint @AndroidEntryPoint
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() {
@Inject @Inject
lateinit var groupConfigFactory: ConfigFactory lateinit var groupConfigFactory: ConfigFactory
@ -80,9 +76,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
private val memberListAdapter by lazy { private val memberListAdapter by lazy {
if (isSelfAdmin) if (isSelfAdmin)
EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick) EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick)
else else
EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin) EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin)
} }
private lateinit var mainContentContainer: LinearLayout private lateinit var mainContentContainer: LinearLayout
@ -129,7 +125,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
findViewById<RecyclerView>(R.id.rvUserList).apply { findViewById<RecyclerView>(R.id.rvUserList).apply {
adapter = memberListAdapter adapter = memberListAdapter
layoutManager = LinearLayoutManager(this@EditClosedGroupActivity) layoutManager = LinearLayoutManager(this@EditLegacyGroupActivity)
} }
lblGroupNameDisplay.text = originalName lblGroupNameDisplay.text = originalName
@ -162,13 +158,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<GroupMembers> { LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<GroupMembers> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> {
return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID) return EditLegacyClosedGroupLoader(this@EditLegacyGroupActivity, groupID)
} }
override fun onLoadFinished(loader: Loader<GroupMembers>, groupMembers: GroupMembers) { override fun onLoadFinished(loader: Loader<GroupMembers>, groupMembers: GroupMembers) {
// We no longer need any subsequent loading events // We no longer need any subsequent loading events
// (they will occur on every activity resume). // (they will occur on every activity resume).
LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID) LoaderManager.getInstance(this@EditLegacyGroupActivity).destroyLoader(loaderID)
members.clear() members.clear()
members.addAll(groupMembers.members.toHashSet()) members.addAll(groupMembers.members.toHashSet())
@ -192,7 +188,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
// endregion // endregion
// region Updating // region Updating
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
when (requestCode) { when (requestCode) {
@ -252,7 +247,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
} }
private fun onAddMembersClick() { private fun onAddMembersClick() {
val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java) val intent = Intent(this@EditLegacyGroupActivity, SelectContactsActivity::class.java)
intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray()) intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add") intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
startActivityForResult(intent, addUsersRequestCode) startActivityForResult(intent, addUsersRequestCode)
@ -320,10 +315,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
if (isClosedGroup) { if (isClosedGroup) {
isLoading = true isLoading = true
loaderContainer.fadeIn() loaderContainer.fadeIn()
val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { try {
if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
MessageSender.explicitLeave(groupPublicKey!!, false) MessageSender.explicitLeave(groupPublicKey!!, false)
} else { } else {
task {
if (hasNameChanged) { if (hasNameChanged) {
MessageSender.explicitNameChange(groupPublicKey!!, name) MessageSender.explicitNameChange(groupPublicKey!!, name)
} }
@ -334,15 +329,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() }) if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
} }
} }
}
promise.successUi {
loaderContainer.fadeOut() loaderContainer.fadeOut()
isLoading = false isLoading = false
updateGroupConfig() updateGroupConfig()
finish() finish()
}.failUi { exception -> } catch (exception: Exception) {
val message = if (exception is MessageSender.Error) exception.description else "An error occurred" val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() Toast.makeText(this@EditLegacyGroupActivity, message, Toast.LENGTH_LONG).show()
loaderContainer.fadeOut() loaderContainer.fadeOut()
isLoading = false isLoading = false
} }
@ -350,8 +343,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
} }
private fun updateGroupConfig() { private fun updateGroupConfig() {
val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
?: return Log.w("Loki", "No recipient settings when trying to update group config")
val latestGroup = storage.getGroup(groupID) val latestGroup = storage.getGroup(groupID)
?: return Log.w("Loki", "No group record when trying to update group config") ?: return Log.w("Loki", "No group record when trying to update group config")
groupConfigFactory.updateLegacyGroup(latestGroup) groupConfigFactory.updateLegacyGroup(latestGroup)

View File

@ -9,12 +9,12 @@ import com.bumptech.glide.RequestManager
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
class EditClosedGroupMembersAdapter( class EditLegacyGroupMembersAdapter(
private val context: Context, private val context: Context,
private val glide: RequestManager, private val glide: RequestManager,
private val admin: Boolean, private val admin: Boolean,
private val memberClickListener: ((String) -> Unit)? = null private val memberClickListener: ((String) -> Unit)? = null
) : RecyclerView.Adapter<EditClosedGroupMembersAdapter.ViewHolder>() { ) : RecyclerView.Adapter<EditLegacyGroupMembersAdapter.ViewHolder>() {
private val members = ArrayList<String>() private val members = ArrayList<String>()
private val zombieMembers = ArrayList<String>() private val zombieMembers = ArrayList<String>()

View File

@ -0,0 +1,2 @@
package org.thoughtcrime.securesms.groups

View File

@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.groups
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.search.getSearchName
@OptIn(FlowPreview::class)
@HiltViewModel(assistedFactory = SelectContactsViewModel.Factory::class)
class SelectContactsViewModel @AssistedInject constructor(
private val storage: StorageProtocol,
private val configFactory: ConfigFactory,
@Assisted private val excludingAccountIDs: Set<String>,
@Assisted private val scope: CoroutineScope
) : ViewModel() {
// Input: The search query
private val mutableSearchQuery = MutableStateFlow("")
// Input: The selected contact account IDs
private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet<String>())
// Output: The search query
val searchQuery: StateFlow<String> get() = mutableSearchQuery
// Output: the contact items to display and select from
val contacts: StateFlow<List<ContactItem>> = combine(
observeContacts(),
mutableSearchQuery.debounce(100L),
mutableSelectedContactAccountIDs,
::filterContacts
).stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// Output
val currentSelected: Set<Contact>
get() = contacts.value
.asSequence()
.filter { it.selected }
.map { it.contact }
.toSet()
override fun onCleared() {
super.onCleared()
scope.cancel()
}
private fun observeContacts() = (configFactory.configUpdateNotifications as Flow<Any>)
.debounce(100L)
.onStart { emit(Unit) }
.map {
withContext(Dispatchers.Default) {
val allContacts = storage.getAllContacts()
if (excludingAccountIDs.isEmpty()) {
allContacts
} else {
allContacts.filterNot { it.accountID in excludingAccountIDs }
}
}
}
private fun filterContacts(
contacts: Collection<Contact>,
query: String,
selectedAccountIDs: Set<String>
): List<ContactItem> {
return contacts
.asSequence()
.filter {
query.isBlank() ||
it.name?.contains(query, ignoreCase = true) == true ||
it.nickname?.contains(query, ignoreCase = true) == true
}
.map { contact ->
ContactItem(
contact = contact,
selected = selectedAccountIDs.contains(contact.accountID)
)
}
.toList()
}
fun onSearchQueryChanged(query: String) {
mutableSearchQuery.value = query
}
fun onContactItemClicked(accountID: String) {
val newSet = mutableSelectedContactAccountIDs.value.toHashSet()
if (!newSet.remove(accountID)) {
newSet.add(accountID)
}
mutableSelectedContactAccountIDs.value = newSet
}
@AssistedFactory
interface Factory {
fun create(
excludingAccountIDs: Set<String> = emptySet(),
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
): SelectContactsViewModel
}
}
data class ContactItem(
val contact: Contact,
val selected: Boolean,
) {
val accountID: String get() = contact.accountID
val name: String get() = contact.getSearchName()
}

View File

@ -0,0 +1,168 @@
package org.thoughtcrime.securesms.groups.compose
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.groups.ContactItem
import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
@Composable
fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.background(LocalColors.current.warning)
) {
Text(
text = stringResource(R.string.groupInviteVersion),
color = LocalColors.current.textAlert,
style = LocalType.current.small,
maxLines = 2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp)
)
}
}
fun LazyListScope.multiSelectMemberList(
contacts: List<ContactItem>,
modifier: Modifier = Modifier,
onContactItemClicked: (accountId: String) -> Unit,
enabled: Boolean = true,
) {
items(contacts) { contact ->
Column {
Row(
modifier = modifier
.fillMaxWidth()
.toggleable(
enabled = enabled,
value = contact.selected,
onValueChange = { onContactItemClicked(contact.accountID) },
role = Role.Checkbox
)
.padding(vertical = 8.dp, horizontal = 24.dp),
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ContactPhoto(
contact.accountID,
)
MemberName(name = contact.name)
Checkbox(
checked = contact.selected,
onCheckedChange = null,
colors = CheckboxDefaults.colors(checkedColor = LocalColors.current.primary),
enabled = enabled,
)
}
HorizontalDivider(color = LocalColors.current.borders)
}
}
}
val MemberNameStyle = TextStyle(fontWeight = FontWeight.Bold)
@Composable
fun RowScope.MemberName(
name: String,
modifier: Modifier = Modifier
) = Text(
text = name,
style = MemberNameStyle,
modifier = modifier
.weight(1f)
.align(CenterVertically)
)
@Composable
fun RowScope.ContactPhoto(sessionId: String) {
return if (LocalInspectionMode.current) {
Image(
painterResource(id = R.drawable.ic_profile_default),
colorFilter = ColorFilter.tint(LocalColors.current.textSecondary),
contentScale = ContentScale.Inside,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.border(1.dp, LocalColors.current.borders, CircleShape)
)
} else {
val context = LocalContext.current
// Ideally we migrate to something that doesn't require recipient, or get contact photo another way
val recipient = remember(sessionId) {
Recipient.from(context, Address.fromSerialized(sessionId), false)
}
Avatar(recipient)
}
}
@Preview
@Composable
fun PreviewMemberList() {
val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
PreviewTheme {
LazyColumn {
multiSelectMemberList(
contacts = listOf(
ContactItem(
Contact(random, "Person"),
selected = false,
),
ContactItem(
Contact(random, "Cow"),
selected = true,
)
),
onContactItemClicked = {}
)
}
}
}

View File

@ -0,0 +1,169 @@
package org.thoughtcrime.securesms.groups.compose
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.groups.ContactItem
import org.thoughtcrime.securesms.groups.CreateGroupEvent
import org.thoughtcrime.securesms.groups.CreateGroupViewModel
import org.thoughtcrime.securesms.ui.CloseIcon
import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.NavigationBar
import org.thoughtcrime.securesms.ui.SearchBar
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
@Composable
fun CreateGroupScreen(
onNavigateToConversationScreen: (threadID: Long) -> Unit,
onBack: () -> Unit,
onClose: () -> Unit,
) {
val viewModel: CreateGroupViewModel = hiltViewModel()
val context = LocalContext.current
LaunchedEffect(viewModel) {
viewModel.events.collect { event ->
when (event) {
is CreateGroupEvent.NavigateToConversation -> {
onClose()
onNavigateToConversationScreen(event.threadID)
}
is CreateGroupEvent.Error -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
}
CreateGroup(
groupName = viewModel.groupName.collectAsState().value,
onGroupNameChanged = viewModel::onGroupNameChanged,
groupNameError = viewModel.groupNameError.collectAsState().value,
contactSearchQuery = viewModel.selectContactsViewModel.searchQuery.collectAsState().value,
onContactSearchQueryChanged = viewModel.selectContactsViewModel::onSearchQueryChanged,
onContactItemClicked = viewModel.selectContactsViewModel::onContactItemClicked,
showLoading = viewModel.isLoading.collectAsState().value,
items = viewModel.selectContactsViewModel.contacts.collectAsState().value,
onCreateClicked = viewModel::onCreateClicked,
onBack = onBack,
onClose = onClose,
)
}
@Composable
fun CreateGroup(
groupName: String,
onGroupNameChanged: (String) -> Unit,
groupNameError: String,
contactSearchQuery: String,
onContactSearchQueryChanged: (String) -> Unit,
onContactItemClicked: (accountID: String) -> Unit,
showLoading: Boolean,
items: List<ContactItem>,
onCreateClicked: () -> Unit,
onBack: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val focusManager = LocalFocusManager.current
Column(
modifier = modifier.padding(bottom = LocalDimensions.current.mediumSpacing),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
NavigationBar(
title = stringResource(id = R.string.groupCreate),
onBack = onBack,
actionElement = { CloseIcon(onClose) }
)
SessionOutlinedTextField(
text = groupName,
onChange = onGroupNameChanged,
placeholder = stringResource(R.string.groupNameEnter),
textStyle = LocalType.current.base,
modifier = Modifier.padding(horizontal = 16.dp),
error = groupNameError.takeIf { it.isNotBlank() },
enabled = !showLoading,
onContinue = focusManager::clearFocus
)
SearchBar(
query = contactSearchQuery,
onValueChanged = onContactSearchQueryChanged,
placeholder = stringResource(R.string.searchContacts),
modifier = Modifier.padding(horizontal = 16.dp),
enabled = !showLoading
)
LazyColumn(modifier = Modifier.weight(1f)) {
multiSelectMemberList(
contacts = items,
onContactItemClicked = onContactItemClicked,
enabled = !showLoading
)
}
PrimaryOutlineButton(onClick = onCreateClicked, modifier = Modifier.widthIn(min = 120.dp)) {
LoadingArcOr(loading = showLoading) {
Text(stringResource(R.string.create))
}
}
}
}
@Preview
@Composable
private fun CreateGroupPreview(
) {
val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
val previewMembers = listOf(
ContactItem(Contact(random, name = "Alice"), false),
ContactItem(Contact(random, name = "Bob"), true),
)
PreviewTheme {
CreateGroup(
modifier = Modifier.background(LocalColors.current.backgroundSecondary),
groupName = "Group Name",
onGroupNameChanged = {},
contactSearchQuery = "",
onContactSearchQueryChanged = {},
onContactItemClicked = {},
items = previewMembers,
onBack = {},
onClose = {},
onCreateClicked = {},
showLoading = false,
groupNameError = "",
)
}
}

View File

@ -0,0 +1,504 @@
package org.thoughtcrime.securesms.groups.compose
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.squareup.phrase.Phrase
import kotlinx.serialization.Serializable
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.thoughtcrime.securesms.groups.EditGroupViewModel
import org.thoughtcrime.securesms.groups.GroupMemberState
import org.thoughtcrime.securesms.ui.AlertDialog
import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.NavigationBar
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.bold
@Composable
fun EditGroupScreen(
groupSessionId: String,
onFinish: () -> Unit,
) {
val navController = rememberNavController()
val viewModel = hiltViewModel<EditGroupViewModel, EditGroupViewModel.Factory> { factory ->
factory.create(groupSessionId)
}
NavHost(navController = navController, startDestination = RouteEditGroup) {
composable<RouteEditGroup> {
EditGroup(
onBackClick = onFinish,
onAddMemberClick = { navController.navigate(RouteSelectContacts) },
onResendInviteClick = viewModel::onResendInviteClicked,
onPromoteClick = viewModel::onPromoteContact,
onRemoveClick = viewModel::onRemoveContact,
onEditNameClicked = viewModel::onEditNameClicked,
onEditNameCancelClicked = viewModel::onCancelEditingNameClicked,
onEditNameConfirmed = viewModel::onEditNameConfirmClicked,
onEditingNameValueChanged = viewModel::onEditingNameChanged,
editingName = viewModel.editingName.collectAsState().value,
members = viewModel.members.collectAsState().value,
groupName = viewModel.groupName.collectAsState().value,
showAddMembers = viewModel.showAddMembers.collectAsState().value,
canEditName = viewModel.canEditGroupName.collectAsState().value,
onResendPromotionClick = viewModel::onResendPromotionClicked,
showingError = viewModel.error.collectAsState().value,
onErrorDismissed = viewModel::onDismissError,
)
}
composable<RouteSelectContacts> {
SelectContactsScreen(
excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection,
onDoneClicked = {
viewModel.onContactSelected(it)
navController.popBackStack()
},
onBackClicked = { navController.popBackStack() },
)
}
}
}
@Serializable
private object RouteEditGroup
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditGroup(
onBackClick: () -> Unit,
onAddMemberClick: () -> Unit,
onResendInviteClick: (accountId: String) -> Unit,
onResendPromotionClick: (accountId: String) -> Unit,
onPromoteClick: (accountId: String) -> Unit,
onRemoveClick: (accountId: String, removeMessages: Boolean) -> Unit,
onEditingNameValueChanged: (String) -> Unit,
editingName: String?,
onEditNameClicked: () -> Unit,
onEditNameConfirmed: () -> Unit,
onEditNameCancelClicked: () -> Unit,
canEditName: Boolean,
groupName: String,
members: List<GroupMemberState>,
showAddMembers: Boolean,
showingError: String?,
onErrorDismissed: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState()
val (showingBottomModelForMember, setShowingBottomModelForMember) = remember {
mutableStateOf<GroupMemberState?>(null)
}
val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember {
mutableStateOf<GroupMemberState?>(null)
}
Scaffold(
topBar = {
NavigationBar(
title = stringResource(id = R.string.groupEdit),
onBack = onBackClick,
actionElement = {
TextButton(onClick = onBackClick) {
Text(
text = stringResource(id = R.string.done),
color = LocalColors.current.text,
style = LocalType.current.large.bold()
)
}
}
)
}
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
GroupMinimumVersionBanner()
// Group name title
Row(
modifier = Modifier
.animateContentSize()
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = CenterVertically,
) {
if (editingName != null) {
IconButton(onClick = onEditNameCancelClicked) {
Icon(
painter = painterResource(R.drawable.ic_x),
contentDescription = stringResource(R.string.AccessibilityId_cancel),
tint = LocalColors.current.text,
)
}
SessionOutlinedTextField(
modifier = Modifier.width(180.dp),
text = editingName,
onChange = onEditingNameValueChanged,
textStyle = LocalType.current.large
)
IconButton(onClick = onEditNameConfirmed) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = stringResource(R.string.AccessibilityId_confirm),
tint = LocalColors.current.text,
)
}
} else {
Text(
text = groupName,
style = LocalType.current.h3,
textAlign = TextAlign.Center,
)
if (canEditName) {
IconButton(onClick = onEditNameClicked) {
Icon(
painterResource(R.drawable.ic_baseline_edit_24),
contentDescription = stringResource(R.string.groupName),
tint = LocalColors.current.text,
)
}
}
}
}
// Header & Add member button
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = CenterVertically
) {
Text(
stringResource(R.string.groupMembers),
modifier = Modifier.weight(1f),
style = LocalType.current.large,
color = LocalColors.current.text
)
if (showAddMembers) {
PrimaryOutlineButton(
stringResource(R.string.membersInvite),
onClick = onAddMemberClick
)
}
}
// List of members
LazyColumn(modifier = Modifier) {
items(members) { member ->
// Each member's view
MemberItem(
modifier = Modifier.fillMaxWidth(),
member = member,
onClick = { setShowingBottomModelForMember(member) }
)
}
}
}
}
if (showingBottomModelForMember != null) {
MemberModalBottomSheetOptions(
onDismissRequest = { setShowingBottomModelForMember(null) },
sheetState = sheetState,
onRemove = {
setShowingConfirmRemovingMember(showingBottomModelForMember)
setShowingBottomModelForMember(null)
},
onPromote = {
setShowingBottomModelForMember(null)
onPromoteClick(showingBottomModelForMember.accountId)
},
onResendInvite = {
setShowingBottomModelForMember(null)
onResendInviteClick(showingBottomModelForMember.accountId)
},
onResendPromotion = {
setShowingBottomModelForMember(null)
onResendPromotionClick(showingBottomModelForMember.accountId)
},
member = showingBottomModelForMember,
)
}
if (showingConfirmRemovingMember != null) {
ConfirmRemovingMemberDialog(
onDismissRequest = {
setShowingConfirmRemovingMember(null)
},
onConfirmed = onRemoveClick,
member = showingConfirmRemovingMember,
groupName = groupName,
)
}
if (!showingError.isNullOrEmpty()) {
Snackbar(
dismissAction = {
TextButton(onClick = onErrorDismissed) {
Text(text = stringResource(id = R.string.dismiss))
}
},
content = {
Text(text = showingError)
}
)
}
}
@Composable
private fun ConfirmRemovingMemberDialog(
onConfirmed: (accountId: String, removeMessages: Boolean) -> Unit,
onDismissRequest: () -> Unit,
member: GroupMemberState,
groupName: String,
) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismissRequest,
text = Phrase.from(context, R.string.groupRemoveDescription)
.put(NAME_KEY, member.name)
.put(GROUP_NAME_KEY, groupName)
.format()
.toString(),
title = stringResource(R.string.remove),
buttons = listOf(
DialogButtonModel(
text = GetString(R.string.remove),
color = LocalColors.current.danger,
onClick = { onConfirmed(member.accountId, false) }
),
DialogButtonModel(
text = GetString(R.string.groupRemoveMessages),
color = LocalColors.current.danger,
onClick = { onConfirmed(member.accountId, true) }
),
DialogButtonModel(
text = GetString(R.string.cancel),
onClick = onDismissRequest,
)
)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MemberModalBottomSheetOptions(
member: GroupMemberState,
onRemove: () -> Unit,
onPromote: () -> Unit,
onResendInvite: () -> Unit,
onResendPromotion: () -> Unit,
onDismissRequest: () -> Unit,
sheetState: SheetState,
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
) {
if (member.canRemove) {
val context = LocalContext.current
MemberModalBottomSheetOptionItem(
onClick = onRemove,
text = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1)
)
}
if (member.canPromote) {
MemberModalBottomSheetOptionItem(
onClick = onPromote,
text = stringResource(R.string.adminPromoteToAdmin)
)
}
if (member.canResendInvite) {
MemberModalBottomSheetOptionItem(onClick = onResendInvite, text = "Resend invite")
}
if (member.canResendPromotion) {
MemberModalBottomSheetOptionItem(onClick = onResendPromotion, text = "Resend promotion")
}
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
private fun MemberModalBottomSheetOptionItem(
text: String,
onClick: () -> Unit
) {
Text(
modifier = Modifier
.clickable(onClick = onClick)
.padding(16.dp)
.fillMaxWidth(),
style = LocalType.current.base,
text = text,
color = LocalColors.current.text,
)
}
@Composable
private fun MemberItem(
onClick: (accountId: String) -> Unit,
member: GroupMemberState,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = CenterVertically,
) {
ContactPhoto(member.accountId)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
style = LocalType.current.large,
text = member.name,
color = LocalColors.current.text
)
if (member.status.isNotEmpty()) {
Text(
text = member.status,
style = LocalType.current.small,
color = if (member.highlightStatus) {
LocalColors.current.danger
} else {
LocalColors.current.textSecondary
},
)
}
}
if (member.canEdit) {
IconButton(onClick = { onClick(member.accountId) }) {
Icon(
painter = painterResource(R.drawable.ic_circle_dot_dot_dot),
contentDescription = stringResource(R.string.AccessibilityId_sessionSettings)
)
}
}
}
}
@Preview
@Composable
private fun EditGroupPreview() {
PreviewTheme {
val oneMember = GroupMemberState(
accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
name = "Test User",
status = "Invited",
highlightStatus = false,
canPromote = true,
canRemove = true,
canResendInvite = false,
canResendPromotion = false,
)
val twoMember = GroupMemberState(
accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235",
name = "Test User 2",
status = "Promote failed",
highlightStatus = true,
canPromote = true,
canRemove = true,
canResendInvite = false,
canResendPromotion = false,
)
val threeMember = GroupMemberState(
accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236",
name = "Test User 3",
status = "",
highlightStatus = false,
canPromote = true,
canRemove = true,
canResendInvite = false,
canResendPromotion = false,
)
val (editingName, setEditingName) = remember { mutableStateOf<String?>(null) }
EditGroup(
onBackClick = {},
onAddMemberClick = {},
onResendInviteClick = {},
onPromoteClick = {},
onRemoveClick = { _, _ -> },
onEditNameCancelClicked = {
setEditingName(null)
},
onEditNameConfirmed = {
setEditingName(null)
},
onEditNameClicked = {
setEditingName("Test Group")
},
editingName = editingName,
onEditingNameValueChanged = setEditingName,
members = listOf(oneMember, twoMember, threeMember),
canEditName = true,
groupName = "Test",
showAddMembers = true,
onResendPromotionClick = {},
showingError = "Error",
onErrorDismissed = {}
)
}
}

View File

@ -0,0 +1,146 @@
package org.thoughtcrime.securesms.groups.compose
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush.Companion.verticalGradient
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.serialization.Serializable
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.groups.ContactItem
import org.thoughtcrime.securesms.groups.SelectContactsViewModel
import org.thoughtcrime.securesms.ui.CloseIcon
import org.thoughtcrime.securesms.ui.NavigationBar
import org.thoughtcrime.securesms.ui.SearchBar
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
@Serializable
object RouteSelectContacts
@Composable
fun SelectContactsScreen(
excludingAccountIDs: Set<String> = emptySet(),
onDoneClicked: (selectedContacts: Set<Contact>) -> Unit,
onBackClicked: () -> Unit,
) {
val viewModel = hiltViewModel<SelectContactsViewModel, SelectContactsViewModel.Factory> { factory ->
factory.create(excludingAccountIDs)
}
SelectContacts(
contacts = viewModel.contacts.collectAsState().value,
onContactItemClicked = viewModel::onContactItemClicked,
searchQuery = viewModel.searchQuery.collectAsState().value,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
onDoneClicked = { onDoneClicked(viewModel.currentSelected) },
onBack = onBackClicked,
)
}
@Composable
fun SelectContacts(
contacts: List<ContactItem>,
onContactItemClicked: (accountId: String) -> Unit,
searchQuery: String,
onSearchQueryChanged: (String) -> Unit,
onDoneClicked: () -> Unit,
onBack: () -> Unit,
onClose: (() -> Unit)? = null,
@StringRes okButtonResId: Int = R.string.ok
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
NavigationBar(
title = stringResource(id = R.string.contactSelect),
onBack = onBack,
actionElement = {
if (onClose != null) {
CloseIcon(onClose)
}
}
)
GroupMinimumVersionBanner()
SearchBar(
query = searchQuery,
onValueChanged = onSearchQueryChanged,
placeholder = stringResource(R.string.searchContacts),
modifier = Modifier.padding(horizontal = 16.dp),
backgroundColor = LocalColors.current.backgroundSecondary,
)
LazyColumn(modifier = Modifier.weight(1f)) {
multiSelectMemberList(
contacts = contacts,
onContactItemClicked = onContactItemClicked,
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.background(
verticalGradient(
0f to Color.Transparent,
0.2f to LocalColors.current.background,
)
)
) {
PrimaryOutlineButton(
onClick = onDoneClicked,
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
.defaultMinSize(minWidth = 128.dp),
) {
Text(
stringResource(id = okButtonResId)
)
}
}
}
}
@Preview
@Composable
private fun PreviewSelectContacts() {
PreviewTheme {
SelectContacts(
contacts = listOf(
ContactItem(
contact = Contact(accountID = "123", name = "User 1"),
selected = false,
),
ContactItem(
contact = Contact(accountID = "124", name = "User 2"),
selected = true,
),
),
onContactItemClicked = {},
searchQuery = "",
onSearchQueryChanged = {},
onDoneClicked = {},
onBack = {},
onClose = null
)
}
}

View File

@ -9,6 +9,8 @@ import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.getConversationUnread import org.thoughtcrime.securesms.util.getConversationUnread
@ -21,7 +23,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
// is not the best idea. It doesn't survive configuration change. // is not the best idea. It doesn't survive configuration change.
// We should be dealing with IDs and all sorts of serializable data instead // We should be dealing with IDs and all sorts of serializable data instead
// if we want to use dialog fragments properly. // if we want to use dialog fragments properly.
lateinit var publicKey: String
lateinit var thread: ThreadRecord lateinit var thread: ThreadRecord
var group: GroupRecord? = null
@Inject lateinit var configFactory: ConfigFactory @Inject lateinit var configFactory: ConfigFactory
@ -51,6 +55,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.blockTextView -> onBlockTapped?.invoke() binding.blockTextView -> onBlockTapped?.invoke()
binding.unblockTextView -> onUnblockTapped?.invoke() binding.unblockTextView -> onUnblockTapped?.invoke()
binding.deleteTextView -> onDeleteTapped?.invoke() binding.deleteTextView -> onDeleteTapped?.invoke()
binding.leaveTextView -> onDeleteTapped?.invoke()
binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke() binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke()
binding.notificationsTextView -> onNotificationTapped?.invoke() binding.notificationsTextView -> onNotificationTapped?.invoke()
binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false) binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false)
@ -62,6 +67,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
if (!this::thread.isInitialized) { return dismiss() } if (!this::thread.isInitialized) { return dismiss() }
val recipient = thread.recipient val recipient = thread.recipient
val isCurrentUserInGroup = group?.members?.map { it.toString() }?.contains(publicKey) ?: false
if (!recipient.isGroupRecipient && !recipient.isLocalNumber) { if (!recipient.isGroupRecipient && !recipient.isLocalNumber) {
binding.detailsTextView.visibility = View.VISIBLE binding.detailsTextView.visibility = View.VISIBLE
binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE
@ -82,7 +88,10 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.muteNotificationsTextView.setOnClickListener(this) binding.muteNotificationsTextView.setOnClickListener(this)
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
binding.notificationsTextView.setOnClickListener(this) binding.notificationsTextView.setOnClickListener(this)
binding.deleteTextView.isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup)
binding.deleteTextView.setOnClickListener(this) binding.deleteTextView.setOnClickListener(this)
binding.leaveTextView.isVisible = recipient.isGroupRecipient && isCurrentUserInGroup
binding.leaveTextView.setOnClickListener(this)
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
binding.markAllAsReadTextView.setOnClickListener(this) binding.markAllAsReadTextView.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned binding.pinTextView.isVisible = !thread.isPinned

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
@ -16,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationBinding import network.loki.messenger.databinding.ViewConversationBinding
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
@ -50,6 +50,16 @@ class ConversationView : LinearLayout {
// region Updating // region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean) { fun bind(thread: ThreadRecord, isTyping: Boolean) {
if (thread.isLeavingGroup) {
binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
} else if (thread.isErrorLeavingGroup) {
binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
binding.snippetTextView.setTextColor(context.getColorFromAttr(R.attr.danger))
} else {
binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
}
this.thread = thread this.thread = thread
if (thread.isPinned) { if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(

View File

@ -36,6 +36,7 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.LibSessionGroupLeavingJob
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
@ -43,6 +44,7 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.ProfilePictureModifiedEvent
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
@ -71,7 +73,6 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager import com.bumptech.glide.RequestManager
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
@ -116,7 +117,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory @Inject lateinit var configFactory: ConfigFactory
@Inject lateinit var pushRegistry: PushRegistry
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>() private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>() private val homeViewModel by viewModels<HomeViewModel>()
@ -140,9 +140,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
} }
is GlobalSearchAdapter.Model.Contact -> push<ConversationActivityV2> { is GlobalSearchAdapter.Model.Contact -> push<ConversationActivityV2> {
putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized)) putExtra(
ConversationActivityV2.ADDRESS,
model.contact.accountID.let(Address::fromSerialized)
)
} }
is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId
is GlobalSearchAdapter.Model.LegacyGroupConversation -> model.groupRecord.encodedId
.let { Recipient.from(this, Address.fromSerialized(it), false) } .let { Recipient.from(this, Address.fromSerialized(it), false) }
.let(threadDb::getThreadIdIfExistsFor) .let(threadDb::getThreadIdIfExistsFor)
.takeIf { it >= 0 } .takeIf { it >= 0 }
@ -238,7 +242,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
(applicationContext as ApplicationContext).startPollingIfNeeded() (applicationContext as ApplicationContext).startPollingIfNeeded()
// update things based on TextSecurePrefs (profile info etc) // update things based on TextSecurePrefs (profile info etc)
// Set up remaining components if needed // Set up remaining components if needed
pushRegistry.refresh(false)
if (textSecurePreferences.getLocalNumber() != null) { if (textSecurePreferences.getLocalNumber() != null) {
OpenGroupManager.startPolling() OpenGroupManager.startPolling()
JobQueue.shared.resumePendingJobs() JobQueue.shared.resumePendingJobs()
@ -330,7 +333,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val GlobalSearchResult.contactAndGroupList: List<GlobalSearchAdapter.Model> get() = private val GlobalSearchResult.contactAndGroupList: List<GlobalSearchAdapter.Model> get() =
contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } + contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } +
threads.map(GlobalSearchAdapter.Model::GroupConversation) threads.map(GlobalSearchAdapter.Model::LegacyGroupConversation)
private val GlobalSearchResult.messageResults: List<GlobalSearchAdapter.Model> get() { private val GlobalSearchResult.messageResults: List<GlobalSearchAdapter.Model> get() {
val unreadThreadMap = messages val unreadThreadMap = messages
@ -428,7 +431,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
override fun onLongConversationClick(thread: ThreadRecord) { override fun onLongConversationClick(thread: ThreadRecord) {
val bottomSheet = ConversationOptionsBottomSheet(this) val bottomSheet = ConversationOptionsBottomSheet(this)
bottomSheet.publicKey = publicKey
bottomSheet.thread = thread bottomSheet.thread = thread
bottomSheet.group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull()
bottomSheet.onViewDetailsTapped = { bottomSheet.onViewDetailsTapped = {
bottomSheet.dismiss() bottomSheet.dismiss()
val userDetailsBottomSheet = UserDetailsBottomSheet() val userDetailsBottomSheet = UserDetailsBottomSheet()
@ -588,14 +593,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
val group = groupDatabase.getGroup(recipient.address.toString()).orNull() val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
// If you are an admin of this group you can delete it // If you are an admin of this group you can delete it
if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) { if (group != null && group.admins.map { it.toString() }
.contains(textSecurePreferences.getLocalNumber())) {
title = getString(R.string.groupDelete) title = getString(R.string.groupDelete)
message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription) message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription)
.put(GROUP_NAME_KEY, group.title) .put(GROUP_NAME_KEY, group.title)
.format() .format()
} else { } else {
// Otherwise this is either a community, or it's a group you're not an admin of // Otherwise this is either a community, or it's a group you're not an admin of
title = if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(R.string.groupLeave) title =
if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(
R.string.groupLeave
)
message = Phrase.from(this.applicationContext, R.string.groupLeaveDescription) message = Phrase.from(this.applicationContext, R.string.groupLeaveDescription)
.put(GROUP_NAME_KEY, group.title) .put(GROUP_NAME_KEY, group.title)
.format() .format()
@ -622,25 +631,34 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val context = this@HomeActivity val context = this@HomeActivity
// Cancel any outstanding jobs // Cancel any outstanding jobs
DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) DatabaseComponent.get(context).sessionJobDatabase()
.cancelPendingMessageSendJobs(threadID)
// Send a leave group message if this is an active closed group // Send a leave group message if this is an active closed group
if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { if (recipient.address.isLegacyClosedGroup && DatabaseComponent.get(context)
.groupDatabase().isActive(recipient.address.toGroupString())
) {
try { try {
GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() GroupUtil.doubleDecodeGroupID(recipient.address.toString())
.toHexString()
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
?.let { MessageSender.explicitLeave(it, false) } ?.let { MessageSender.explicitLeave(it, true, deleteThread = true) }
} catch (ioe: IOException) { } catch (ioe: IOException) {
Log.w(TAG, "Got an IOException while sending leave group message") Log.w(TAG, "Got an IOException while sending leave group message", ioe)
} }
} }
if (recipient.address.isClosedGroupV2) {
val groupLeave = LibSessionGroupLeavingJob(AccountId(recipient.address.serialize()), true)
JobQueue.shared.add(groupLeave)
}
// Delete the conversation // Delete the conversation
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase()
.getOpenGroupChat(threadID)
if (v2OpenGroup != null) { if (v2OpenGroup != null) {
v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) } OpenGroupManager.delete(
} else { v2OpenGroup.server,
lifecycleScope.launch(Dispatchers.IO) { v2OpenGroup.room,
threadDb.deleteConversation(threadID) context
} )
} }
// Update the badge count // Update the badge count
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)

View File

@ -80,7 +80,7 @@ class HomeViewModel @Inject constructor(
).flowOn(Dispatchers.IO) ).flowOn(Dispatchers.IO)
private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
.map { threadDb.unapprovedConversationCount } .map { threadDb.unapprovedConversationList.use { cursor -> cursor.count } }
private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges() private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges()
.map { threadDb.latestUnapprovedConversationTimestamp } .map { threadDb.latestUnapprovedConversationTimestamp }

View File

@ -11,7 +11,7 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.GetString
import java.security.InvalidParameterException import java.security.InvalidParameterException
@ -116,7 +116,7 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
fun bind(query: String, model: Model) { fun bind(query: String, model: Model) {
binding.searchResultProfilePicture.recycle() binding.searchResultProfilePicture.recycle()
when (model) { when (model) {
is Model.GroupConversation -> bindModel(query, model) is Model.LegacyGroupConversation -> bindModel(query, model)
is Model.Contact -> bindModel(query, model) is Model.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model) is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(model) is Model.SavedMessages -> bindModel(model)
@ -137,7 +137,8 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
} }
data class SavedMessages(val currentUserPublicKey: String): Model() data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model() data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model()
data class GroupConversation(val groupRecord: GroupRecord): Model() data class LegacyGroupConversation(val groupRecord: GroupRecord) : Model()
data class ClosedGroupConversation(val sessionId: AccountId)
data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : Model() data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : Model()
} }
} }

View File

@ -14,7 +14,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsession.utilities.truncateIdForDisplay
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.LegacyGroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
@ -66,7 +66,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
binding.searchResultSubtitle.isVisible = true binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName() binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
} }
is GroupConversation -> { is LegacyGroupConversation -> {
binding.searchResultTitle.text = getHighlight( binding.searchResultTitle.text = getHighlight(
query, query,
model.groupRecord.title model.groupRecord.title
@ -87,9 +87,9 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query) return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query)
} }
fun ContentView.bindModel(query: String?, model: GroupConversation) { fun ContentView.bindModel(query: String?, model: LegacyGroupConversation) {
binding.searchResultProfilePicture.isVisible = true binding.searchResultProfilePicture.isVisible = true
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup
binding.searchResultTimestamp.isVisible = false binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
binding.searchResultProfilePicture.update(threadRecipient) binding.searchResultProfilePicture.update(threadRecipient)
@ -99,7 +99,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) } val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) }
val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName) val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
if (model.groupRecord.isClosedGroup) { if (model.groupRecord.isLegacyClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString) binding.searchResultSubtitle.text = getHighlight(query, membersString)
} }
} }
@ -127,13 +127,6 @@ fun ContentView.bindModel(model: SavedMessages) {
fun ContentView.bindModel(query: String?, model: Message) = binding.apply { fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
searchResultProfilePicture.isVisible = true searchResultProfilePicture.isVisible = true
searchResultTimestamp.isVisible = true searchResultTimestamp.isVisible = true
// val hasUnreads = model.unread > 0
// unreadCountIndicator.isVisible = hasUnreads
// if (hasUnreads) {
// unreadCountTextView.text = model.unread.toString()
// }
searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
searchResultProfilePicture.update(model.messageResult.conversationRecipient) searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder() val textSpannable = SpannableStringBuilder()

View File

@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityMessageRequestsBinding import network.loki.messenger.databinding.ActivityMessageRequestsBinding
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
@ -80,7 +82,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
override fun onBlockConversationClick(thread: ThreadRecord) { override fun onBlockConversationClick(thread: ThreadRecord) {
fun doBlock() { fun doBlock() {
viewModel.blockMessageRequest(thread) val recipient = thread.invitingAdminId?.let {
Recipient.from(this, Address.fromSerialized(it), false)
} ?: thread.recipient
viewModel.blockMessageRequest(thread, recipient)
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
} }
@ -108,7 +113,11 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
showSessionDialog { showSessionDialog {
title(R.string.delete) title(R.string.delete)
text(resources.getString(R.string.messageRequestsDelete)) text(resources.getString(R.string.messageRequestsDelete))
button(R.string.delete) { doDecline() } if (thread.recipient.isClosedGroupV2Recipient) {
dangerButton(R.string.delete, contentDescriptionRes = R.string.delete) { doDecline() }
} else {
dangerButton(R.string.decline, contentDescriptionRes = R.string.decline) { doDecline() }
}
button(R.string.cancel) button(R.string.cancel)
} }
} }

View File

@ -31,7 +31,9 @@ class MessageRequestsAdapter(
val view = MessageRequestView(context) val view = MessageRequestView(context)
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
view.setOnLongClickListener { view.setOnLongClickListener {
view.thread?.let { showPopupMenu(view) } view.thread?.let { thread ->
showPopupMenu(view, thread.recipient.isGroupRecipient, thread.invitingAdminId)
}
true true
} }
return ViewHolder(view) return ViewHolder(view)
@ -47,10 +49,14 @@ class MessageRequestsAdapter(
holder?.view?.recycle() holder?.view?.recycle()
} }
private fun showPopupMenu(view: MessageRequestView) { private fun showPopupMenu(view: MessageRequestView, groupRecipient: Boolean, invitingAdmin: String?) {
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
// still show the block option if we have an inviting admin for the group
if ((groupRecipient && invitingAdmin == null) || view.thread!!.recipient.isOpenGroupInboxRecipient) {
popupMenu.menuInflater.inflate(R.menu.menu_group_request, popupMenu.menu)
} else {
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient }
popupMenu.setOnMenuItemClickListener { menuItem -> popupMenu.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.menu_delete_message_request) { if (menuItem.itemId == R.id.menu_delete_message_request) {
listener.onDeleteConversationClick(view.thread!!) listener.onDeleteConversationClick(view.thread!!)

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import javax.inject.Inject import javax.inject.Inject
@ -13,13 +14,11 @@ class MessageRequestsViewModel @Inject constructor(
private val repository: ConversationRepository private val repository: ConversationRepository
) : ViewModel() { ) : ViewModel() {
fun blockMessageRequest(thread: ThreadRecord) = viewModelScope.launch { // We assume thread.recipient is a contact or thread.invitingAdmin is not null
val recipient = thread.recipient fun blockMessageRequest(thread: ThreadRecord, blockRecipient: Recipient) = viewModelScope.launch {
if (recipient.isContactRecipient) { repository.setBlocked(thread.threadId, blockRecipient, true)
repository.setBlocked(recipient, true)
deleteMessageRequest(thread) deleteMessageRequest(thread)
} }
}
fun deleteMessageRequest(thread: ThreadRecord) = viewModelScope.launch { fun deleteMessageRequest(thread: ThreadRecord) = viewModelScope.launch {
repository.deleteMessageRequest(thread) repository.deleteMessageRequest(thread)

View File

@ -7,20 +7,24 @@ import androidx.work.Constraints
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import kotlinx.coroutines.GlobalScope
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.MessageReceiveParameters
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.pollers.OpenGroupPoller import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.utilities.asyncPromise
import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.recover import org.session.libsignal.utilities.recover
@ -108,20 +112,23 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
var dmsPromise: Promise<Unit, Exception> = Promise.ofSuccess(Unit) var dmsPromise: Promise<Unit, Exception> = Promise.ofSuccess(Unit)
if (requestTargets.contains(Targets.DMS)) { if (requestTargets.contains(Targets.DMS)) {
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth)
dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes -> dmsPromise = SnodeAPI.getMessages(userAuth).bind { envelopes ->
val params = envelopes.map { (envelope, serverHash) -> val params = envelopes.map { (envelope, serverHash) ->
// FIXME: Using a job here seems like a bad idea... // FIXME: Using a job here seems like a bad idea...
MessageReceiveParameters(envelope.toByteArray(), serverHash, null) MessageReceiveParameters(envelope.toByteArray(), serverHash, null)
} }
GlobalScope.asyncPromise {
BatchMessageReceiveJob(params).executeAsync("background") BatchMessageReceiveJob(params).executeAsync("background")
} }
}
promises.add(dmsPromise) promises.add(dmsPromise)
} }
// Closed groups // Closed groups
if (requestTargets.contains(Targets.CLOSED_GROUPS)) { if (requestTargets.contains(Targets.CLOSED_GROUPS)) {
val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared val closedGroupPoller = LegacyClosedGroupPollerV2() // Intentionally don't use shared
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) }

View File

@ -43,11 +43,9 @@ import kotlin.concurrent.Volatile
import me.leolin.shortcutbadger.ShortcutBadger import me.leolin.shortcutbadger.ShortcutBadger
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.ServiceUtil import org.session.libsession.utilities.ServiceUtil
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy
@ -56,6 +54,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.hasHidde
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Util import org.session.libsignal.utilities.Util

View File

@ -6,13 +6,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.AsyncTask import android.os.AsyncTask
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared
import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.messages.control.ReadReceipt
import org.session.libsession.messaging.sending_receiving.MessageSender.send import org.session.libsession.messaging.sending_receiving.MessageSender.send
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeAPI.nowWithOffset
import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
import org.session.libsession.utilities.associateByNotNull import org.session.libsession.utilities.associateByNotNull
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
@ -102,7 +102,7 @@ class MarkReadReceiver : BroadcastReceiver() {
SnodeAPI.alterTtl( SnodeAPI.alterTtl(
messageHashes = hashes, messageHashes = hashes,
newExpiry = nowWithOffset + expiresIn, newExpiry = nowWithOffset + expiresIn,
publicKey = TextSecurePreferences.getLocalNumber(context)!!, auth = checkNotNull(shared.storage.userAuth) { "No authorized user" },
shorten = true shorten = true
) )
} }
@ -130,7 +130,7 @@ class MarkReadReceiver : BroadcastReceiver() {
hashToMessage: Map<String, MarkedMessageInfo> hashToMessage: Map<String, MarkedMessageInfo>
) { ) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map<String, Long> val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), shared.storage.userAuth!!).get()["expiries"] as Map<String, Long>
hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } } hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } }
} }

View File

@ -1,54 +1,110 @@
package org.thoughtcrime.securesms.notifications package org.thoughtcrime.securesms.notifications
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat.getString import androidx.core.content.ContextCompat.getString
import com.goterl.lazysodium.interfaces.AEAD import com.goterl.lazysodium.interfaces.AEAD
import com.goterl.lazysodium.utils.Key import com.goterl.lazysodium.utils.Key
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.GroupInfo
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.MessageReceiveParameters
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata
import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsession.snode.GroupSubAccountSwarmAuth
import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.Bencode
import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeList
import org.session.libsession.utilities.bencode.BencodeString import org.session.libsession.utilities.bencode.BencodeString
import org.session.libsession.utilities.withGroupConfigsOrNull
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.Envelope
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "PushHandler" private const val TAG = "PushHandler"
class PushReceiver @Inject constructor(@ApplicationContext val context: Context) { class PushReceiver @Inject constructor(
@ApplicationContext private val context: Context,
private val configFactory: ConfigFactory
) {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
fun onPush(dataMap: Map<String, String>?) { fun onPush(dataMap: Map<String, String>?) {
onPush(dataMap?.asByteArray()) val result = dataMap?.decodeAndDecrypt()
} val data = result?.first
fun onPush(data: ByteArray?) {
if (data == null) { if (data == null) {
onPush() onPush()
return return
} }
handlePushData(data = data, metadata = result.second)
}
private fun handlePushData(data: ByteArray, metadata: PushNotificationMetadata?) {
try { try {
val envelopeAsData = MessageWrapper.unwrap(data).toByteArray() val params = when {
val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null) metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> {
JobQueue.shared.add(job) val groupId = AccountId(requireNotNull(metadata.account) {
"Received a closed group message push notification without an account ID"
})
val envelop = checkNotNull(tryDecryptGroupMessage(groupId, data)) {
"Unable to decrypt closed group message"
}
MessageReceiveParameters(
data = envelop.toByteArray(),
serverHash = metadata.msg_hash,
closedGroup = Destination.ClosedGroup(groupId.hexString)
)
}
metadata?.namespace == 0 || metadata == null -> {
MessageReceiveParameters(
data = MessageWrapper.unwrap(data).toByteArray(),
)
}
else -> {
Log.w(TAG, "Received a push notification with an unknown namespace: ${metadata.namespace}")
return
}
}
JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null))
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "Failed to unwrap data for message due to error.", e) Log.d(TAG, "Failed to unwrap data for message due to error.", e)
} }
} }
private fun tryDecryptGroupMessage(groupId: AccountId, data: ByteArray): Envelope? {
return configFactory.withGroupConfigsOrNull(groupId) { _, _, keys ->
val (envelopBytes, sender) = checkNotNull(keys.decrypt(data)) {
"Failed to decrypt group message"
}
Log.d(TAG, "Successfully decrypted group message from ${sender.hexString}")
Envelope.parseFrom(envelopBytes)
.toBuilder()
.setSource(sender.hexString)
.build()
}
}
private fun onPush() { private fun onPush() {
Log.d(TAG, "Failed to decode data for message.") Log.d(TAG, "Failed to decode data for message.")
val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER) val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER)
@ -61,10 +117,13 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true) .setAutoCancel(true)
if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(11111, builder.build()) NotificationManagerCompat.from(context).notify(11111, builder.build())
} }
}
private fun Map<String, String>.asByteArray() = private fun Map<String, String>.decodeAndDecrypt() =
when { when {
// this is a v2 push notification // this is a v2 push notification
containsKey("spns") -> { containsKey("spns") -> {
@ -76,18 +135,20 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
} }
} }
// old v1 push notification; we still need this for receiving legacy closed group notifications // old v1 push notification; we still need this for receiving legacy closed group notifications
else -> this["ENCRYPTED_DATA"]?.let(Base64::decode) else -> this["ENCRYPTED_DATA"]?.let { Base64.decode(it) to null }
} }
private fun decrypt(encPayload: ByteArray): ByteArray? { private fun decrypt(encPayload: ByteArray): Pair<ByteArray?, PushNotificationMetadata?> {
Log.d(TAG, "decrypt() called") Log.d(TAG, "decrypt() called")
val encKey = getOrCreateNotificationKey() val encKey = getOrCreateNotificationKey()
val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES)
val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() val payload =
encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size)
val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce)
?: error("Failed to decrypt push notification") ?: error("Failed to decrypt push notification")
val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray() val contentEndedAt = padded.indexOfLast { it.toInt() != 0 }
val decrypted = if (contentEndedAt >= 0) padded.sliceArray(0..contentEndedAt) else padded
val bencoded = Bencode.Decoder(decrypted) val bencoded = Bencode.Decoder(decrypted)
val expectedList = (bencoded.decode() as? BencodeList)?.values val expectedList = (bencoded.decode() as? BencodeList)?.values
?: error("Failed to decode bencoded list from payload") ?: error("Failed to decode bencoded list from payload")
@ -99,20 +160,18 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
// null content is valid only if we got a "data_too_long" flag // null content is valid only if we got a "data_too_long" flag
it?.let { check(metadata.data_len == it.size) { "wrong message data size" } } it?.let { check(metadata.data_len == it.size) { "wrong message data size" } }
?: check(metadata.data_too_long) { "missing message data, but no too-long flag" } ?: check(metadata.data_too_long) { "missing message data, but no too-long flag" }
} } to metadata
} }
fun getOrCreateNotificationKey(): Key { fun getOrCreateNotificationKey(): Key {
if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) { val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY)
if (keyHex != null) {
return Key.fromHexString(keyHex)
}
// generate the key and store it // generate the key and store it
val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
} return key
return Key.fromHexString(
IdentityKeyUtil.retrieve(
context,
IdentityKeyUtil.NOTIFICATION_KEY
)
)
} }
} }

View File

@ -0,0 +1,235 @@
package org.thoughtcrime.securesms.notifications
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.scan
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 org.session.libsession.database.userAuth
import org.session.libsession.messaging.notifications.TokenFetcher
import org.session.libsession.snode.GroupSubAccountSwarmAuth
import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.snode.SwarmAuth
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.withGroupConfigsOrNull
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
private const val TAG = "PushRegistrationHandler"
/**
* A class that listens to the config, user's preference, token changes and
* register/unregister push notification accordingly.
*
* This class DOES NOT handle the legacy groups push notification.
*/
class PushRegistrationHandler
@Inject
constructor(
private val pushRegistry: PushRegistryV2,
private val configFactory: ConfigFactory,
private val preferences: TextSecurePreferences,
private val storage: Storage,
private val tokenFetcher: TokenFetcher,
) {
@OptIn(DelicateCoroutinesApi::class)
private val scope: CoroutineScope = GlobalScope
private var job: Job? = null
@OptIn(FlowPreview::class)
fun run() {
require(job == null) { "Job is already running" }
job = scope.launch(Dispatchers.Default) {
combine(
configFactory.configUpdateNotifications
.debounce(500L)
.onStart { emit(Unit) },
IdentityKeyUtil.CHANGES.onStart { emit(Unit) },
preferences.pushEnabled,
tokenFetcher.token,
) { _, _, enabled, token ->
if (!enabled || token.isNullOrEmpty()) {
return@combine emptyMap<SubscriptionKey, Subscription>()
}
val userAuth =
storage.userAuth ?: return@combine emptyMap<SubscriptionKey, Subscription>()
getGroupSubscriptions(
token = token,
userSecretKey = userAuth.ed25519PrivateKey
) + mapOf(
SubscriptionKey(userAuth.accountId, token) to OwnedSubscription(
userAuth,
0
)
)
}
.scan<Map<SubscriptionKey, Subscription>, Pair<Map<SubscriptionKey, Subscription>, Map<SubscriptionKey, Subscription>>?>(
null
) { acc, current ->
val prev = acc?.second.orEmpty()
prev to current
}
.filterNotNull()
.collect { (prev, current) ->
val addedAccountIds = current.keys - prev.keys
val removedAccountIDs = prev.keys - current.keys
if (addedAccountIds.isNotEmpty()) {
Log.d(TAG, "Adding ${addedAccountIds.size} new subscriptions")
}
if (removedAccountIDs.isNotEmpty()) {
Log.d(TAG, "Removing ${removedAccountIDs.size} subscriptions")
}
val deferred = mutableListOf<Deferred<*>>()
addedAccountIds.mapTo(deferred) { key ->
val subscription = current.getValue(key)
async {
try {
subscription.withAuth { auth ->
pushRegistry.register(
token = key.token,
swarmAuth = auth,
namespaces = listOf(subscription.namespace)
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to register for push notification", e)
}
}
}
removedAccountIDs.mapTo(deferred) { key ->
val subscription = prev.getValue(key)
async {
try {
subscription.withAuth { auth ->
pushRegistry.unregister(
token = key.token,
swarmAuth = auth,
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to unregister for push notification", e)
}
}
}
deferred.awaitAll()
}
}
}
private fun getGroupSubscriptions(
token: String,
userSecretKey: ByteArray
): Map<SubscriptionKey, Subscription> {
return buildMap {
val groups = configFactory.userGroups?.allClosedGroupInfo().orEmpty()
for (group in groups) {
val adminKey = group.adminKey
if (adminKey != null && adminKey.isNotEmpty()) {
put(
SubscriptionKey(group.groupAccountId, token),
OwnedSubscription(
auth = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey),
namespace = Namespace.GROUPS()
)
)
continue
}
val authData = group.authData
if (authData != null && authData.isNotEmpty()) {
val subscription =
configFactory.withGroupConfigsOrNull(group.groupAccountId) { info, members, keys ->
SubAccountSubscription(
authData = authData,
groupInfoConfigDump = info.dump(),
groupMembersConfigDump = members.dump(),
groupKeysConfigDump = keys.dump(),
groupId = group.groupAccountId,
userSecretKey = userSecretKey
)
}
if (subscription != null) {
put(SubscriptionKey(group.groupAccountId, token), subscription)
}
}
}
}
}
private data class SubscriptionKey(
val accountId: AccountId,
val token: String,
)
private sealed interface Subscription {
suspend fun withAuth(cb: suspend (SwarmAuth) -> Unit)
val namespace: Int
}
private class OwnedSubscription(val auth: OwnedSwarmAuth, override val namespace: Int) :
Subscription {
override suspend fun withAuth(cb: suspend (SwarmAuth) -> Unit) {
cb(auth)
}
}
private class SubAccountSubscription(
val groupId: AccountId,
val userSecretKey: ByteArray,
val authData: ByteArray,
val groupInfoConfigDump: ByteArray,
val groupMembersConfigDump: ByteArray,
val groupKeysConfigDump: ByteArray
) : Subscription {
override suspend fun withAuth(cb: suspend (SwarmAuth) -> Unit) {
GroupInfoConfig.newInstance(groupId.pubKeyBytes, initialDump = groupInfoConfigDump)
.use { info ->
GroupMembersConfig.newInstance(
groupId.pubKeyBytes,
initialDump = groupMembersConfigDump
).use { members ->
GroupKeysConfig.newInstance(
userSecretKey = userSecretKey,
groupPublicKey = groupId.pubKeyBytes,
initialDump = groupKeysConfigDump,
info = info,
members = members
).use { keys ->
cb(GroupSubAccountSwarmAuth(keys, groupId, authData))
}
}
}
}
override val namespace: Int
get() = Namespace.GROUPS()
}
}

View File

@ -1,111 +0,0 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import com.goterl.lazysodium.utils.KeyPair
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.combine.and
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.session.libsignal.utilities.emptyPromise
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import javax.inject.Inject
import javax.inject.Singleton
private val TAG = PushRegistry::class.java.name
@Singleton
class PushRegistry @Inject constructor(
@ApplicationContext private val context: Context,
private val device: Device,
private val tokenManager: TokenManager,
private val pushRegistryV2: PushRegistryV2,
private val prefs: TextSecurePreferences,
private val tokenFetcher: TokenFetcher,
) {
private var pushRegistrationJob: Job? = null
fun refresh(force: Boolean): Job {
Log.d(TAG, "refresh() called with: force = $force")
pushRegistrationJob?.apply {
if (force) cancel() else if (isActive) return MainScope().launch {}
}
return MainScope().launch(Dispatchers.IO) {
try {
register(tokenFetcher.fetch()).get()
} catch (e: Exception) {
Log.e(TAG, "register failed", e)
}
}.also { pushRegistrationJob = it }
}
fun register(token: String?): Promise<*, Exception> {
Log.d(TAG, "refresh() called")
if (token?.isNotEmpty() != true) return emptyPromise()
prefs.setPushToken(token)
val userPublicKey = prefs.getLocalNumber() ?: return emptyPromise()
val userEdKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return emptyPromise()
return when {
prefs.isPushEnabled() -> register(token, userPublicKey, userEdKey)
tokenManager.isRegistered -> unregister(token, userPublicKey, userEdKey)
else -> emptyPromise()
}
}
/**
* Register for push notifications.
*/
private fun register(
token: String,
publicKey: String,
userEd25519Key: KeyPair,
namespaces: List<Int> = listOf(Namespace.DEFAULT)
): Promise<*, Exception> {
Log.d(TAG, "register() called")
val v1 = PushRegistryV1.register(
device = device,
token = token,
publicKey = publicKey
) fail {
Log.e(TAG, "register v1 failed", it)
}
val v2 = pushRegistryV2.register(
device, token, publicKey, userEd25519Key, namespaces
) fail {
Log.e(TAG, "register v2 failed", it)
}
return v1 and v2 success {
Log.d(TAG, "register v1 & v2 success")
tokenManager.register()
}
}
private fun unregister(
token: String,
userPublicKey: String,
userEdKey: KeyPair
): Promise<*, Exception> = PushRegistryV1.unregister() and pushRegistryV2.unregister(
device, token, userPublicKey, userEdKey
) fail {
Log.e(TAG, "unregisterBoth failed", it)
} success {
tokenManager.unregister()
}
}

Some files were not shown because too many files have changed in this diff Show More