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',
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
'apt-get install -y ninja-build',
'apt-get install -y ninja-build openjdk-17-jdk-headless',
'./gradlew testPlayDebugUnitTestCoverageReport'
],
}
@ -78,7 +78,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
'apt-get install -y ninja-build',
'apt-get install -y ninja-build openjdk-17-jdk-headless',
'./gradlew assemblePlayDebug',
'./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
.settings
.kotlin
bin/
gen/
.idea/

View File

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

View File

@ -3,5 +3,8 @@
<application>
<uses-library android:name="android.test.runner"
android:required="false" />
<activity android:name="androidx.activity.ComponentActivity"/>
</application>
</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
import android.Manifest
import android.app.Instrumentation
import android.view.View
import android.content.ClipboardManager
import android.content.Context
import androidx.test.espresso.Espresso.onView
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.assertion.ViewAssertions.matches
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.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SmallTest
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.UiDevice
import com.adevinta.android.barista.interaction.PermissionGranter
import com.bumptech.glide.Glide
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.not
import org.junit.After
@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.home.HomeActivity
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
@SmallTest
class HomeActivityTests {
@get:Rule
@ -108,6 +108,7 @@ class HomeActivityTests {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
// new chat
Thread.sleep(500)
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
onView(withId(R.id.copyButton)).perform(ViewActions.click())
val context = InstrumentationRegistry.getInstrumentation().targetContext
@ -147,12 +148,14 @@ class HomeActivityTests {
setupLoggedInState()
goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
sendMessage("howdy")
sendMessage("test")
// tests url rewriter doesn't crash
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
sendMessage("https://www.ámazon.com")
}
}
@Test
fun testChat_displaysCorrectUrl() {
@ -161,7 +164,9 @@ class HomeActivityTests {
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
// given the link url text
val url = "https://www.ámazon.com"
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
}
// when the URL span is clicked
onView(withSubstring(url)).perform(ViewActions.click())
@ -175,21 +180,4 @@ class HomeActivityTests {
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.Conversation
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.instanceOf
import org.hamcrest.MatcherAssert.assertThat
@ -31,32 +35,15 @@ import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@SmallTest
class LibSessionTests {
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0
private val nextFakeHash: String
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 {
val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.newInstance(key)
@ -98,11 +85,10 @@ class LibSessionTests {
@Test
fun migration_one_to_ones() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storage = applicationContext.applySpiedStorage()
val newContactId = randomAccountId()
val newContactId = randomSessionId()
val singleContact = Contact(
id = newContactId,
approved = true,
@ -111,10 +97,10 @@ class LibSessionTests {
val newContactMerge = buildContactMessage(listOf(singleContact))
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat {
verify(storage).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1
}, any())
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
}
@Test
@ -123,7 +109,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage)
app.storage = storageSpy
val randomRecipient = randomAccountId()
val randomRecipient = randomSessionId()
val newContact = Contact(
id = randomRecipient,
approved = true,
@ -158,7 +144,7 @@ class LibSessionTests {
app.storage = storageSpy
// Initial state
val randomRecipient = randomAccountId()
val randomRecipient = randomSessionId()
val currentContact = Contact(
id = randomRecipient,
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"
/>
<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:screenOrientation="portrait" />
<activity
@ -237,7 +243,13 @@
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</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
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait"

View File

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

View File

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

View File

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

View File

@ -215,13 +215,29 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
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? {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = Address.fromSerialized(author)
val message = database.getMessageFor(timestamp, address) ?: return null
updateMessageAsDeleted(message.id, !message.isMms)
val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase()
else DatabaseComponent.get(context).smsDatabase()
messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention)
messagingDatabase.markAsDeleted(message.id)
if (message.isOutgoing) {
messagingDatabase.deleteMessage(message.id)
}

View File

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

View File

@ -8,7 +8,6 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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> {
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.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import network.loki.messenger.R
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.LokiAPIDatabase
import org.thoughtcrime.securesms.ui.getSubbedString
import org.thoughtcrime.securesms.database.Storage
import javax.inject.Inject
@AndroidEntryPoint
class ConversationActionBarView @JvmOverloads constructor(
@ -42,6 +43,7 @@ class ConversationActionBarView @JvmOverloads constructor(
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var groupDb: GroupDatabase
@Inject lateinit var storage: Storage
var delegate: ConversationActionBarDelegate? = null
@ -51,6 +53,9 @@ class ConversationActionBarView @JvmOverloads constructor(
}
}
val profilePictureView
get() = binding.profilePictureView
init {
var previousState: Int
var currentState = 0
@ -80,7 +85,7 @@ class ConversationActionBarView @JvmOverloads constructor(
) {
this.delegate = delegate
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) }
update(recipient, openGroup, config)
}
@ -141,7 +146,11 @@ class ConversationActionBarView @JvmOverloads constructor(
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
resources.getQuantityString(R.plurals.membersActive, userCount, userCount)
} 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)
}
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.libsession_util.util.ExpiryMode
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.control.ExpirationTimerUpdate
import org.session.libsession.messaging.sending_receiving.MessageSender
@ -43,8 +44,12 @@ class DisappearingMessages @Inject constructor(
messageExpirationManager.insertExpirationTimerMessage(message)
MessageSender.send(message, address)
if (address.isClosedGroupV2) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.from(address))
} else {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
title(R.string.disappearingMessagesFollowSetting)
@ -58,9 +63,9 @@ class DisappearingMessages @Inject constructor(
dangerButton(
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()
}

View File

@ -59,16 +59,28 @@ class DisappearingMessagesViewModel(
init {
viewModelScope.launch {
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
val recipient = threadDb.getRecipientForThreadId(threadId)
val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient }
val recipient = threadDb.getRecipientForThreadId(threadId) ?: return@launch
val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient }
?.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 {
it.copy(
address = recipient?.address,
isGroup = groupRecord != null,
isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(),
isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() },
address = recipient.address,
isGroup = recipient.isGroupRecipient,
isNoteToSelf = recipient.address.serialize() == textSecurePreferences.getLocalNumber(),
isSelfAdmin = isAdmin,
expiryMode = 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() {
replaceFragment(CreateGroupFragment().also { it.delegate = this })
replaceFragment(CreateGroupFragment())
}
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.thoughtcrime.securesms.ui.components.AppBarCloseIcon
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.SlimOutlineCopyButton
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.rememberNestedScrollInteropConnection
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.PreviewParameter
import androidx.compose.ui.unit.Dp
@ -142,8 +144,10 @@ private fun EnterAccountId(
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
.padding(horizontal = LocalDimensions.current.spacing),
contentDescription = "Session id input box",
.padding(horizontal = LocalDimensions.current.spacing)
.semantics {
contentDescription = "Session id input box"
},
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,

View File

@ -59,14 +59,23 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collect
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.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.libsession_util.util.ExpiryMode
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration
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.link_preview.LinkPreview
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.SnodeAPI
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.ListenableFuture
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey
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.conversation.ConversationActionBarDelegate
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.OnReactionSelectedListener
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.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
@ -155,6 +165,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.getSearchName
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
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.theme.SessionMaterialTheme
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils
@ -211,7 +221,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
ConversationMenuHelper.ConversationMenuListener {
ConversationMenuHelper.ConversationMenuListener, View.OnClickListener {
private lateinit var binding: ActivityConversationV2Binding
@ -224,7 +234,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
@Inject lateinit var storage: Storage
@Inject lateinit var storage: StorageProtocol
@Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.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 linkPreviewViewModel: LinkPreviewViewModel by lazy {
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
@ -269,7 +286,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
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 unreadCount = Int.MAX_VALUE
@ -404,6 +421,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_GIF = 10
const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124
const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result
}
// endregion
@ -478,11 +496,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updatePlaceholder()
setUpBlockedBanner()
binding.searchBottomBar.setEventListener(this)
binding.toolbarContent.profilePictureView.setOnClickListener(this)
updateSendAfterApprovalText()
setUpMessageRequestsBar()
// Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
// keyboard visible and have no need to immediately display it.
setUpMessageRequests()
val weakActivity = WeakReference(this)
@ -506,6 +522,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
setUpOutdatedClientBanner()
setUpLegacyGroupBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding.conversationRecyclerView.scrollToPosition(targetPosition)
@ -574,6 +591,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
override fun finish() {
super.finish()
}
override fun onPause() {
super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1)
@ -809,13 +830,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
legacyRecipient != null
binding.outdatedBanner.isVisible = shouldShowLegacy
binding.outdatedDisappearingBanner.isVisible = shouldShowLegacy
if (shouldShowLegacy) {
val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy)
.put(NAME_KEY, legacyRecipient!!.name)
.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() {
lifecycleScope.launchWhenStarted {
viewModel.uiState.collect { uiState ->
uiState.uiMessages.firstOrNull()?.let {
Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show()
viewModel.messageShown(it.id)
// Observe toast messages
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.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()
}
}
}
// 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 {
@ -914,11 +977,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (threadRecipient.isContactRecipient) {
binding.blockedBanner.isVisible = threadRecipient.isBlocked
}
setUpMessageRequestsBar()
invalidateOptionsMenu()
updateSendAfterApprovalText()
showOrHideInputIfNeeded()
maybeUpdateToolbar(threadRecipient)
}
}
@ -931,48 +991,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText
}
private fun showOrHideInputIfNeeded() {
binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
?: true
private fun setUpMessageRequests() {
binding.acceptMessageRequestButton.setOnClickListener {
viewModel.acceptMessageRequest()
}
private fun setUpMessageRequestsBar() {
binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread()
binding.messageRequestBar.isVisible = isIncomingMessageRequestThread()
binding.acceptMessageRequestButton.setOnClickListener {
acceptMessageRequest()
}
binding.messageRequestBlock.setOnClickListener {
block(deleteThread = true)
}
binding.declineMessageRequestButton.setOnClickListener {
viewModel.declineMessageRequest()
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
}
finish()
}
}
private fun acceptMessageRequest() {
binding.messageRequestBar.isVisible = false
viewModel.acceptMessageRequest()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.map { it.messageRequestState }
.distinctUntilChanged()
.collectLatest { state ->
binding.messageRequestBar.isVisible = state != MessageRequestUiState.Invisible
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
if (state is MessageRequestUiState.Visible) {
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) {
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
@ -1174,20 +1222,35 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} ?: false
}
override fun onClick(v: View?) {
if (v === binding?.toolbarContent?.profilePictureView) {
// open conversation settings
conversationSettingsCallback.launch(viewModel.threadId)
}
}
override fun block(deleteThread: Boolean) {
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 {
title(R.string.block)
text(
Phrase.from(context, R.string.blockDescription)
.put(NAME_KEY, recipient.name)
.put(NAME_KEY, name)
.format()
)
dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) {
viewModel.block()
// 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()
if (deleteThread) {
@ -1218,8 +1281,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
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) {
if (thread.isClosedGroupRecipient) {
if (thread.isLegacyClosedGroupRecipient) {
groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
}
Intent(this, DisappearingMessagesActivity::class.java)
@ -1687,19 +1751,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
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>? {
val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval()
viewModel.beforeSendingTextOnlyMessage()
val text = getMessageBody()
val userPublicKey = textSecurePreferences.getLocalNumber()
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
@ -1743,7 +1798,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval()
viewModel.beforeSendingAttachments()
// Create the message
val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
message.sentTimestamp = sentTimestamp
@ -2088,7 +2143,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
cancelButton { endActionMode() }
}
// 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()
bottomSheet.recipient = recipient
bottomSheet.onDeleteForMeTapped = {

View File

@ -1,51 +1,60 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
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.StorageProtocol
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
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.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
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.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.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID
class ConversationViewModel(
val threadId: Long,
val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage,
private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider,
database: MmsDatabase,
private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase,
private val appContext: Context,
) : ViewModel() {
val showSendAfterApprovalText: Boolean
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
val uiState: StateFlow<ConversationUiState> = _uiState
private val _uiState = MutableStateFlow(ConversationUiState())
val uiState: StateFlow<ConversationUiState> get() = _uiState
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
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 {
storage.getOpenGroup(threadId)
}
val openGroup: OpenGroup?
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>
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
@ -83,7 +119,7 @@ class ConversationViewModel(
val isMessageRequestThread : Boolean
get() {
val recipient = recipient ?: return false
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved
}
val canReactToMessages: Boolean
@ -97,16 +133,99 @@ class ConversationViewModel(
)
init {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
repository.recipientUpdateFlow(threadId)
.collect { recipient ->
if (recipient == null && _uiState.value.conversationExists) {
_uiState.update { it.copy(conversationExists = false) }
_uiState.update {
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() {
super.onCleared()
@ -135,16 +254,17 @@ class ConversationViewModel(
}
fun block() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
if (recipient.isContactRecipient) {
repository.setBlocked(recipient, true)
// inviting admin will be true if this request is a closed group message request
val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action")
if (recipient.isContactRecipient || recipient.isClosedGroupV2Recipient) {
repository.setBlocked(threadId, recipient, true)
}
}
fun unblock() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
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()
}
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 {
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
stopPlayingAudioMessage(message)
@ -221,19 +336,36 @@ class ConversationViewModel(
fun acceptMessageRequest() = viewModelScope.launch {
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)
.onSuccess {
_uiState.update {
it.copy(isMessageRequestAccepted = true)
it.copy(messageRequestState = MessageRequestUiState.Invisible)
}
withContext(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
}
}
.onFailure {
showMessage("Couldn't accept message request due to error: $it")
_uiState.update { state ->
state.copy(messageRequestState = currentState)
}
}
}
fun declineMessageRequest() {
repository.declineMessageRequest(threadId)
repository.declineMessageRequest(threadId, recipient!!)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
_uiState.update { it.copy(shouldExit = true) }
}
private fun showMessage(message: String) {
@ -278,6 +410,25 @@ class ConversationViewModel(
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
interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@ -288,9 +439,12 @@ class ConversationViewModel(
@Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage,
private val mmsDatabase: MmsDatabase,
private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider,
private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase,
@ApplicationContext
private val context: Context,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@ -300,7 +454,9 @@ class ConversationViewModel(
repository = repository,
storage = storage,
messageDataProvider = messageDataProvider,
database = mmsDatabase
groupDb = groupDb,
threadDb = threadDb,
appContext = context,
) as T
}
}
@ -310,10 +466,24 @@ data class UiMessage(val id: Long, val message: String)
data class ConversationUiState(
val uiMessages: List<UiMessage> = emptyList(),
val isMessageRequestAccepted: Boolean? = null,
val conversationExists: Boolean
val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible,
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?) {
private var triedToRetrieve: Boolean = false
private var _value: T? = null

View File

@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
if (!this::recipient.isInitialized) {
return dismiss()
}
if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
if (recipient.isLocalNumber) {
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.deleteForEveryoneTextView.setOnClickListener(this)
binding.cancelTextView.setOnClickListener(this)

View File

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

View File

@ -10,49 +10,58 @@ import androidx.fragment.app.DialogFragment
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import org.session.libsession.database.StorageProtocol
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.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload
import javax.inject.Inject
/** 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. */
@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
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
val threadId = storage.getThreadId(threadRecipient) ?: run {
dismiss()
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))
val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription)
.put(CONVERSATION_NAME_KEY, recipient.name)
.put(CONVERSATION_NAME_KEY, displayName)
.format()
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val startIndex = explanation.indexOf(displayName)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text(spannable)
button(R.string.download, R.string.AccessibilityId_download) { trust() }
button(R.string.download, R.string.AccessibilityId_download) {
setAutoDownload()
}
cancelButton { dismiss() }
}
private fun trust() {
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID) ?: return
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
contactDB.setContactIsTrusted(contact, true, threadID)
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
dismiss()
private fun setAutoDownload() {
storage.setAutoDownloadAttachments(threadRecipient, true)
JobQueue.shared.createAndStartAttachmentDownload(databaseAttachment)
}
}

View File

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

View File

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

View File

@ -6,10 +6,10 @@ import android.view.Menu
import android.view.MenuItem
import network.loki.messenger.R
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
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 thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!!
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 }
?.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.utilities.NotificationUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showMuteDialog
@ -56,7 +56,7 @@ object ConversationMenuHelper {
// Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu)
// 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)
}
// 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)
if (thread.isClosedGroupRecipient) {
if (thread.isLegacyClosedGroupRecipient) {
inflater.inflate(R.menu.menu_conversation_closed_group, menu)
}
// Open group menu
@ -258,15 +258,15 @@ object ConversationMenuHelper {
}
private fun editClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return }
val intent = Intent(context, EditClosedGroupActivity::class.java)
if (!thread.isLegacyClosedGroupRecipient) { return }
val intent = Intent(context, EditLegacyGroupActivity::class.java)
val groupID: String = thread.address.toGroupString()
intent.putExtra(groupIDKey, groupID)
context.startActivity(intent)
}
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 admins = group.admins

View File

@ -16,6 +16,8 @@ import network.loki.messenger.databinding.ViewControlMessageBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration
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.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
@ -45,6 +47,7 @@ class ControlMessageView : LinearLayout {
binding.expirationTimerView.isGone = true
binding.followSetting.isGone = true
var messageBody: CharSequence = message.getDisplayBody(context)
binding.root.contentDescription = null
binding.textView.text = messageBody
when {
@ -54,7 +57,7 @@ class ControlMessageView : LinearLayout {
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
if (threadRecipient?.isClosedGroupRecipient == true) {
if (threadRecipient?.isClosedGroupV2Recipient == true) {
expirationTimerView.setTimerIcon()
} else {
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
@ -98,6 +101,12 @@ class ControlMessageView : LinearLayout {
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

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.databinding.ViewVisibleMessageContentBinding
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.utilities.ThemeUtil
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.database.model.MessageRecord
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.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor
@ -61,7 +61,6 @@ class VisibleMessageContentView : ConstraintLayout {
glide: RequestManager = Glide.with(this),
thread: Recipient,
searchQuery: String? = null,
contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
suppressThumbnails: Boolean = false
) {
@ -71,8 +70,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.contentParent.mainColor = color
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
val onlyBodyMessage = message is SmsMessageRecord
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE }
val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress }
val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
// reset visibilities / containers
onContentClick.clear()
@ -85,7 +85,6 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.isVisible = false
binding.quoteView.root.isVisible = false
binding.linkPreviewView.root.isVisible = false
binding.untrustedView.root.isVisible = false
binding.voiceMessageView.root.isVisible = false
binding.documentView.root.isVisible = false
binding.albumThumbnailView.root.isVisible = false
@ -100,9 +99,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.text = null
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
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.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null
binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null
binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
@ -140,6 +139,7 @@ class VisibleMessageContentView : ConstraintLayout {
}
when {
// LINK PREVIEW
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
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
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
}
// AUDIO
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
hideBody = true
// Audio attachment
if (contactIsTrusted || message.isOutgoing) {
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
@ -159,26 +160,38 @@ class VisibleMessageContentView : ConstraintLayout {
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
} 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
(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
if (contactIsTrusted || message.isOutgoing) {
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
binding.documentView.root.bind(message, getTextColor(context, message))
} else {
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
hideBody = true
(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() -> {
/*
* Images / Video attachment
*/
if (contactIsTrusted || message.isOutgoing) {
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
// 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
binding.albumThumbnailView.root.bind(
@ -196,13 +209,22 @@ class VisibleMessageContentView : ConstraintLayout {
} else {
hideBody = true
binding.albumThumbnailView.root.clearViews()
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment
firstAttachment?.let { attachment ->
binding.pendingAttachmentView.root.bind(
PendingAttachmentView.AttachmentType.IMAGE,
getTextColor(context,message),
attachment
)
onContentClick.add {
binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment)
}
}
}
}
message.isOpenGroupInvitation -> {
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() }
}
}
@ -239,7 +261,7 @@ class VisibleMessageContentView : ConstraintLayout {
fun recycle() {
arrayOf(
binding.deletedMessageView.root,
binding.untrustedView.root,
binding.pendingAttachmentView.root,
binding.voiceMessageView.root,
binding.openGroupInvitationView.root,
binding.documentView.root,

View File

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

View File

@ -35,6 +35,12 @@ import org.session.libsignal.utilities.Base64;
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.
*
@ -56,6 +62,8 @@ public class IdentityKeyUtil {
public static final String LOKI_SEED = "loki_seed";
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) {
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");
CHANGES.tryEmit(Unit.INSTANCE);
}
public static void delete(Context context, String key) {
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.database.getBlobOrNull
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
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));"
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) {
@ -33,6 +41,49 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
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? {
val db = readableDatabase
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 org.session.libsession.messaging.messages.ExpirationConfiguration
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_PREFIX
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@ -29,7 +29,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
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)
""".trimIndent()
@ -37,7 +37,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
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_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)

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
import org.session.libsession.database.ServerHashToMessageId
import org.session.libsignal.database.LokiMessageDatabaseProtocol
import org.session.libsignal.utilities.Log
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 smsHashTable = "loki_sms_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 serverID = "server_id"
private val friendRequestStatus = "friend_request_status"
@ -23,6 +28,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
private val errorMessage = "error_message"
private val messageType = "message_type"
private val serverHash = "server_hash"
const val invitingSessionId = "inviting_session_id"
@JvmStatic
val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);"
@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);"
@JvmStatic
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 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 {
databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
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(
getMessageTable(mms),
messageHashTable

View File

@ -44,7 +44,8 @@ public class MediaDatabase extends Database {
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
+ 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
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.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 "
+ AttachmentDatabase.DATA + " IS NOT NULL 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";
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.utilities.JsonUtil;
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.model.MessageRecord;
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 markAsDeleted(long messageId, boolean read, boolean hasMention);
public abstract void markAsDeleted(long messageId);
public abstract boolean deleteMessage(long messageId);
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 String getTypeColumn();
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
try {
addToDocument(messageId, MISMATCHED_IDENTITIES,
@ -206,6 +207,19 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
contentValues.put(THREAD_ID, newThreadId);
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 {
private final Address address;

View File

@ -158,7 +158,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
get(context).groupReceiptDatabase()
.update(ourAddress, id, status, timestamp)
get(context).threadDatabase().update(threadId, false, true)
get(context).threadDatabase().update(threadId, false)
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) {
val db = databaseHelper.writableDatabase
db.execSQL(
@ -257,7 +273,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
" WHERE " + ID + " = ?", arrayOf(id.toString() + "")
)
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()))
}
override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) {
override fun markAsDeleted(messageId: Long) {
val database = databaseHelper.writableDatabase
val contentValues = ContentValues()
contentValues.put(READ, 1)
@ -626,7 +642,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
if (runThreadUpdate) {
get(context).threadDatabase().update(threadId, true, true)
get(context).threadDatabase().update(threadId, true)
}
}
notifyConversationListeners(threadId)
@ -771,7 +787,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
setHasSent(threadId, true)
if (runThreadUpdate) {
update(threadId, true, true)
update(threadId, true)
}
}
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
* @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()
}
override fun getTypeColumn(): String = MESSAGE_BOX
// 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"!
override fun deleteMessage(messageId: Long): Boolean {
@ -909,8 +910,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val groupReceiptDatabase = get(context).groupReceiptDatabase()
groupReceiptDatabase.deleteRowsForMessage(messageId)
val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
database.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
@ -921,6 +922,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val argsArray = messageIds.map { "?" }
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
db.delete(
TABLE_NAME,
@ -928,7 +935,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
argValues
)
val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
@ -956,6 +963,62 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
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(
insertedAttachmentIds: Map<Attachment?, AttachmentId?>,
contacts: List<Contact?>
@ -1099,7 +1162,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return false
}
/*package*/
private fun deleteThreads(threadIds: Set<Long>) {
val db = databaseHelper.writableDatabase
val where = StringBuilder()
@ -1125,7 +1187,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
val threadDb = get(context).threadDatabase()
for (threadId in threadIds) {
val threadDeleted = threadDb.update(threadId, false, true)
val threadDeleted = threadDb.update(threadId, false)
notifyConversationListeners(threadId)
}
notifyStickerListeners()
@ -1133,17 +1195,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
/*package*/
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long) {
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, onlyMedia: Boolean) {
var cursor: Cursor? = null
try {
val db = databaseHelper.readableDatabase
var where =
THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") "
for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) {
where += " WHEN $outgoingType THEN $DATE_SENT < $date"
}
where += " ELSE $DATE_RECEIVED < $date END)"
cursor = db!!.query(
var where = "$THREAD_ID = ? AND $DATE_SENT < $date"
if (onlyMedia) where += " AND $PART_COUNT >= 1"
cursor = db.query(
TABLE_NAME,
arrayOf<String?>(ID),
where,

View File

@ -37,7 +37,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.Closeable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
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.
// Used to do a pass through our local database to remove records when a user has "Ban & Delete"
// 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 WRAPPER_HASH = "wrapper_hash";
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[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
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)
@ -110,6 +111,17 @@ public class RecipientDatabase extends Database {
"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() {
return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
@ -194,6 +206,7 @@ public class RecipientDatabase extends Database {
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1;
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
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,
notifyType,
notifyType, autoDownloadAttachments,
Recipient.DisappearingState.fromId(disappearingState),
Recipient.VibrateState.fromId(messageVibrateState),
Recipient.VibrateState.fromId(callVibrateState),
@ -246,6 +259,22 @@ public class RecipientDatabase extends Database {
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) {
ContentValues values = new ContentValues();
values.put(COLOR, color.serialize());
@ -321,6 +350,21 @@ public class RecipientDatabase extends Database {
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) {
ContentValues values = new ContentValues();
values.put(MUTE_UNTIL, until);

View File

@ -3,10 +3,9 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.json.JSONArray
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.IdPrefix
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) {
companion object {
private const val sessionContactTable = "session_contact_database"
const val sessionContactTable = "session_contact_database"
const val accountID = "session_id"
const val name = "name"
const val nickname = "nickname"
@ -83,23 +82,21 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it))
}
contentValues.put(threadID, contact.threadID)
contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
notifyConversationListListeners()
}
fun contactFromCursor(cursor: Cursor): Contact {
val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID))
val contact = Contact(accountID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL))
contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName))
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let {
val sessionID = cursor.getString(accountID)
val contact = Contact(sessionID)
contact.name = cursor.getStringOrNull(name)
contact.nickname = cursor.getStringOrNull(nickname)
contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
contact.profilePictureEncryptionKey = Base64.decode(it)
}
contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID))
contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0
contact.threadID = cursor.getLong(threadID)
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.BackgroundGroupAddJob
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.MessageReceiveJob
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 }
}
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? {
val database = databaseHelper.readableDatabase
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);
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
}
@ -237,7 +237,7 @@ public class SmsDatabase extends MessagingDatabase {
}
@Override
public void markAsDeleted(long messageId, boolean read, boolean hasMention) {
public void markAsDeleted(long messageId) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues();
contentValues.put(READ, 1);
@ -257,7 +257,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
}
@ -296,6 +296,11 @@ public class SmsDatabase extends MessagingDatabase {
return isOutgoing;
}
@Override
public String getTypeColumn() {
return TYPE;
}
public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
Cursor cursor = null;
@ -320,7 +325,7 @@ public class SmsDatabase extends MessagingDatabase {
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);
foundMessage = true;
}
@ -403,7 +408,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId);
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
notifyConversationListeners(threadId);
notifyConversationListListeners();
@ -478,7 +483,7 @@ public class SmsDatabase extends MessagingDatabase {
long messageId = db.insert(TABLE_NAME, null, values);
if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
}
if (message.getSubscriptionId() != -1) {
@ -570,7 +575,7 @@ public class SmsDatabase extends MessagingDatabase {
}
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();
if (lastSeen < message.getSentTimestampMillis()) {
@ -630,7 +635,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
notifyConversationListeners(threadId);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
return threadDeleted;
}
@ -650,7 +655,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
argValues
);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
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();
String where = THREAD_ID + " = ? AND (CASE " + TYPE;
for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) {
where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date;
db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
}
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 + ""});
}

View File

@ -17,7 +17,7 @@
*/
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.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
@ -124,9 +124,14 @@ public class ThreadDatabase extends Database {
.map(columnName -> TABLE_NAME + "." + columnName)
.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(GroupDatabase.TYPED_GROUP_PROJECTION))
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)),
Stream.of(LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId)
)
.toList();
public static String getCreatePinnedCommand() {
@ -279,9 +284,9 @@ public class ThreadDatabase extends Database {
Log.i("ThreadDatabase", "Cut off tweet date: " + 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);
}
} finally {
@ -293,8 +298,8 @@ public class ThreadDatabase extends Database {
public void trimThreadBefore(long threadId, long timestamp) {
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
update(threadId, false, true);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp, false);
update(threadId, false);
notifyConversationListeners(threadId);
}
@ -428,32 +433,6 @@ public class ThreadDatabase extends Database {
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() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;
@ -492,13 +471,15 @@ public class ThreadDatabase extends Database {
}
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 ";
return getConversationList(where);
}
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.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
@ -722,19 +703,14 @@ public class ThreadDatabase extends Database {
notifyConversationListListeners();
}
public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) {
public boolean update(long threadId, boolean unarchive) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId);
boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId);
MmsSmsDatabase.Reader reader = null;
if (count == 0 && shouldDeleteEmptyThread) {
deleteThread(threadId);
notifyConversationListListeners();
return true;
}
try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) {
try {
reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId));
MessageRecord record = null;
if (reader != null) {
record = reader.getNext();
@ -748,11 +724,7 @@ public class ThreadDatabase extends Database {
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
return false;
} else {
if (shouldDeleteEmptyThread) {
deleteThread(threadId);
return true;
}
// todo: add empty snippet that clears existing data
updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0);
return false;
}
} finally {
@ -800,9 +772,8 @@ public class ThreadDatabase extends Database {
return setLastSeen(threadId, lastSeenTime);
}
private boolean possibleToDeleteThreadOnEmpty(long threadId) {
Recipient threadRecipient = getRecipientForThreadId(threadId);
return threadRecipient != null && !threadRecipient.isCommunityRecipient();
private boolean deleteThreadOnEmpty(long threadId) {
return false;
}
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 +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" LEFT OUTER JOIN " + LokiMessageDatabase.groupInviteTable +
" ON "+ TABLE_NAME + "." + ID + " = " + LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId +
" WHERE " + where +
" 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));
Uri snippetUri = getSnippetUri(cursor);
boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0;
String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId));
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
@ -940,7 +914,7 @@ public class ThreadDatabase extends Database {
return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
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) {

View File

@ -90,9 +90,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV44 = 65;
private static final int lokiV45 = 66;
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
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 String CIPHER3_DATABASE_NAME = "signal.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.getAddBlocksCommunityMessageRequests());
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
@ -628,6 +635,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
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();
} finally {
db.endTransaction();

View File

@ -118,7 +118,7 @@ public abstract class MessageRecord extends DisplayRecord {
public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) {
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()) {
int seconds = (int) (getExpiresIn() / 1000);
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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.messaging.utilities.UpdateMessageBuilder;
import org.session.libsession.messaging.utilities.UpdateMessageData;
import com.squareup.phrase.Phrase;
import org.session.libsession.utilities.ExpirationUtil;
import org.session.libsession.utilities.recipients.Recipient;
@ -57,13 +60,14 @@ public class ThreadRecord extends DisplayRecord {
private final long lastSeen;
private final boolean pinned;
private final int initialRecipientHash;
private final String invitingAdminId;
private final long dateSent;
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
@Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
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);
this.snippetUri = snippetUri;
@ -77,6 +81,7 @@ public class ThreadRecord extends DisplayRecord {
this.lastSeen = lastSeen;
this.pinned = pinned;
this.initialRecipientHash = recipient.hashCode();
this.invitingAdminId = invitingAdminId;
this.dateSent = date;
}
@ -115,6 +120,18 @@ public class ThreadRecord extends DisplayRecord {
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
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));
} else if (isOpenGroupInvitation()) {
return emphasisAdded(context.getString(R.string.communityInvitation));
@ -221,4 +238,30 @@ public class ThreadRecord extends DisplayRecord {
public boolean isPinned() { return pinned; }
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
viewModelScope.launch {
try {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get()
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application)
} catch (e: Exception) {
// 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
import android.content.Context
import android.widget.Toast
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.Toaster
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@ -21,6 +27,17 @@ abstract class AppModule {
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
@InstallIn(SingletonComponent::class)
interface AppComponent {

View File

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

View File

@ -2,16 +2,27 @@ package org.thoughtcrime.securesms.dependencies
import android.content.Context
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.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.GroupInfoConfig
import network.loki.messenger.libsession_util.GroupKeysConfig
import network.loki.messenger.libsession_util.GroupMembersConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import 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.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager
@ -20,6 +31,7 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ConfigFactory(
private val context: Context,
private val configDatabase: ConfigDatabase,
/** <ed25519 secret key,33 byte prefixed public key (hex encoded)> */
private val maybeGetUserInfo: () -> Pair<ByteArray, String>?
) :
ConfigFactoryProtocol {
@ -28,10 +40,10 @@ class ConfigFactory(
// config change, any message which would normally result in a config change which was sent
// before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have
// it's changes applied (control text will still be added though)
val configChangeBufferPeriod: Long = (2 * 60 * 1000)
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()
_contacts?.free()
_convoVolatileConfig?.free()
@ -52,6 +64,13 @@ class ConfigFactory(
private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
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) {
listeners += listener
}
@ -60,7 +79,7 @@ class ConfigFactory(
listeners -= listener
}
private inline fun <T> synchronizedWithLog(lock: Any, body: ()->T): T {
private inline fun <T> synchronizedWithLog(lock: Any, body: () -> T): T {
Trace.beginSection("synchronizedWithLog")
val result = synchronized(lock) {
body()
@ -146,6 +165,101 @@ class ConfigFactory(
_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> =
listOfNotNull(user, contacts, convoVolatile, userGroups)
@ -153,13 +267,23 @@ class ConfigFactory(
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
val dumped = user?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp)
configDatabase.storeConfig(
SharedConfigMessage.Kind.USER_PROFILE.name,
publicKey,
dumped,
timestamp
)
}
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
val dumped = contacts?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp)
configDatabase.storeConfig(
SharedConfigMessage.Kind.CONTACTS.name,
publicKey,
dumped,
timestamp
)
}
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
@ -176,21 +300,52 @@ class ConfigFactory(
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
val dumped = userGroups?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp)
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 {
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 ->
listener.notifyUpdates(forConfigObject, timestamp)
}
when (forConfigObject) {
is UserProfile -> persistUserConfigDump(timestamp)
is Contacts -> persistContactsConfigDump(timestamp)
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(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")
}
_configUpdateNotifications.tryEmit(Unit)
} catch (e: Exception) {
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
}
@ -207,23 +362,25 @@ class ConfigFactory(
if (openGroupId != null) {
val userGroups = userGroups ?: return false
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
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
}
else if (groupPublicKey != null) {
} else if (groupPublicKey != null) {
val userGroups = userGroups ?: return false
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null)
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
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
}
else if (publicKey != null) {
} else if (publicKey != null) {
val contacts = contacts ?: return false
val targetContact = contacts.get(publicKey) ?: return false
@ -233,10 +390,44 @@ class ConfigFactory(
return false
}
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
override fun canPerformChange(
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)
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.components.SingletonComponent
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.*
import org.thoughtcrime.securesms.database.MmsSmsDatabase

View File

@ -141,8 +141,13 @@ object DatabaseModule {
@Provides
@Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
val storage = Storage(context,openHelper, configFactory)
fun provideStorage(@ApplicationContext context: Context,
openHelper: SQLCipherOpenHelper,
configFactory: ConfigFactory,
threadDatabase: ThreadDatabase,
pollerFactory: PollerFactory,
): Storage {
val storage = Storage(context, openHelper, configFactory, pollerFactory)
threadDatabase.setUpdateListener(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.android.qualifiers.ApplicationContext
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.TextSecurePreferences
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.ConfigDatabase
import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionUtilModule {
const val POLLER_SCOPE = "poller_coroutine_scope"
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
return edKey.secretKey.asBytes
@ -33,4 +41,19 @@ object SessionUtilModule {
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 org.session.libsession.messaging.MessagingModuleConfiguration
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.GroupRecord
import org.session.libsession.utilities.GroupUtil
@ -25,7 +25,7 @@ object ClosedGroupManager {
// Notify the PN server
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId)
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
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) {
val groups = userGroups ?: return
if (!group.isClosedGroup) return
if (!group.isLegacyClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage
val threadId = storage.getThreadId(group.encodedId) ?: return
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())

View File

@ -1,127 +1,44 @@
package org.thoughtcrime.securesms.groups
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.compose.ui.platform.ComposeView
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 network.loki.messenger.R
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.NullStartConversationDelegate
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
import javax.inject.Inject
import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
@AndroidEntryPoint
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(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentCreateGroupBinding.inflate(inflater)
return binding.root
}
return ComposeView(requireContext()).apply {
val delegate = (parentFragment as? StartConversationDelegate)
?: (activity as? StartConversationDelegate)
?: NullStartConversationDelegate
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = SelectContactsAdapter(requireContext(), Glide.with(requireContext()))
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() }
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks {
override fun onQueryChanged(query: String) {
adapter.members = viewModel.filter(query).map { it.address.serialize() }
}
}
binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
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)
setContent {
SessionMaterialTheme {
CreateGroupScreen(
onNavigateToConversationScreen = { threadID ->
startActivity(
Intent(requireContext(), ConversationActivityV2::class.java)
.putExtra(ConversationActivityV2.THREAD_ID, threadID)
)
},
onBack = delegate::onDialogBackPressed,
onClose = delegate::onDialogClosePressed
)
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
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.withContext
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
@HiltViewModel
class CreateGroupViewModel @Inject constructor(
private val threadDb: ThreadDatabase,
private val textSecurePreferences: TextSecurePreferences
) : ViewModel() {
configFactory: ConfigFactory,
private val storage: StorageProtocol,
): 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>>()
val recipients: LiveData<List<Recipient>> = _recipients
// Input: group name
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 {
threadDb.approvedConversationList.use { openCursor ->
val reader = threadDb.readerFor(openCursor)
val recipients = mutableListOf<Recipient>()
while (true) {
recipients += reader.next?.recipient ?: break
val groupName = groupName.value.trim()
if (groupName.isBlank()) {
mutableGroupNameError.value = "Group name cannot be empty"
return@launch
}
withContext(Dispatchers.Main) {
_recipients.value = recipients
.filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() }
val selected = selectContactsViewModel.currentSelected
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> {
return _recipients.value?.filter {
it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true
} ?: emptyList()
fun onGroupNameChanged(name: String) {
mutableGroupName.value = if (name.length > MAX_GROUP_NAME_LENGTH) {
name.substring(0, MAX_GROUP_NAME_LENGTH)
} 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.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 members = groupDatabase.getGroupMembers(groupID, true)
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
return EditClosedGroupActivity.GroupMembers(
return EditLegacyGroupActivity.GroupMembers(
members.map {
it.address.toString()
},

View File

@ -18,15 +18,12 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException
import javax.inject.Inject
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.groupSizeLimit
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.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
@AndroidEntryPoint
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var groupConfigFactory: ConfigFactory
@ -80,9 +76,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
private val memberListAdapter by lazy {
if (isSelfAdmin)
EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick)
EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick)
else
EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin)
EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin)
}
private lateinit var mainContentContainer: LinearLayout
@ -129,7 +125,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
findViewById<RecyclerView>(R.id.rvUserList).apply {
adapter = memberListAdapter
layoutManager = LinearLayoutManager(this@EditClosedGroupActivity)
layoutManager = LinearLayoutManager(this@EditLegacyGroupActivity)
}
lblGroupNameDisplay.text = originalName
@ -162,13 +158,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<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) {
// We no longer need any subsequent loading events
// (they will occur on every activity resume).
LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID)
LoaderManager.getInstance(this@EditLegacyGroupActivity).destroyLoader(loaderID)
members.clear()
members.addAll(groupMembers.members.toHashSet())
@ -192,7 +188,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
// endregion
// region Updating
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
@ -252,7 +247,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
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.emptyStateTextKey, "No contacts to add")
startActivityForResult(intent, addUsersRequestCode)
@ -320,10 +315,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
if (isClosedGroup) {
isLoading = true
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)
} else {
task {
if (hasNameChanged) {
MessageSender.explicitNameChange(groupPublicKey!!, name)
}
@ -334,15 +329,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
}
}
}
promise.successUi {
loaderContainer.fadeOut()
isLoading = false
updateGroupConfig()
finish()
}.failUi { exception ->
} catch (exception: Exception) {
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()
isLoading = false
}
@ -350,8 +343,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
private fun updateGroupConfig() {
val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
?: return Log.w("Loki", "No recipient settings when trying to update group config")
val latestGroup = storage.getGroup(groupID)
?: return Log.w("Loki", "No group record when trying to update group config")
groupConfigFactory.updateLegacyGroup(latestGroup)

View File

@ -9,12 +9,12 @@ import com.bumptech.glide.RequestManager
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.TextSecurePreferences
class EditClosedGroupMembersAdapter(
class EditLegacyGroupMembersAdapter(
private val context: Context,
private val glide: RequestManager,
private val admin: Boolean,
private val memberClickListener: ((String) -> Unit)? = null
) : RecyclerView.Adapter<EditClosedGroupMembersAdapter.ViewHolder>() {
) : RecyclerView.Adapter<EditLegacyGroupMembersAdapter.ViewHolder>() {
private val members = 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 dagger.hilt.android.AndroidEntryPoint
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.dependencies.ConfigFactory
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.
// We should be dealing with IDs and all sorts of serializable data instead
// if we want to use dialog fragments properly.
lateinit var publicKey: String
lateinit var thread: ThreadRecord
var group: GroupRecord? = null
@Inject lateinit var configFactory: ConfigFactory
@ -51,6 +55,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.blockTextView -> onBlockTapped?.invoke()
binding.unblockTextView -> onUnblockTapped?.invoke()
binding.deleteTextView -> onDeleteTapped?.invoke()
binding.leaveTextView -> onDeleteTapped?.invoke()
binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke()
binding.notificationsTextView -> onNotificationTapped?.invoke()
binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false)
@ -62,6 +67,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
super.onViewCreated(view, savedInstanceState)
if (!this::thread.isInitialized) { return dismiss() }
val recipient = thread.recipient
val isCurrentUserInGroup = group?.members?.map { it.toString() }?.contains(publicKey) ?: false
if (!recipient.isGroupRecipient && !recipient.isLocalNumber) {
binding.detailsTextView.visibility = View.VISIBLE
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.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
binding.notificationsTextView.setOnClickListener(this)
binding.deleteTextView.isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup)
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.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.text.TextUtils
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
@ -16,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationBinding
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
@ -50,6 +50,16 @@ class ConversationView : LinearLayout {
// region Updating
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
if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(

View File

@ -36,6 +36,7 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.LibSessionGroupLeavingJob
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
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.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString
@ -71,7 +73,6 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
@ -116,7 +117,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory
@Inject lateinit var pushRegistry: PushRegistry
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>()
@ -140,9 +140,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
}
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(threadDb::getThreadIdIfExistsFor)
.takeIf { it >= 0 }
@ -238,7 +242,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
(applicationContext as ApplicationContext).startPollingIfNeeded()
// update things based on TextSecurePrefs (profile info etc)
// Set up remaining components if needed
pushRegistry.refresh(false)
if (textSecurePreferences.getLocalNumber() != null) {
OpenGroupManager.startPolling()
JobQueue.shared.resumePendingJobs()
@ -330,7 +333,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val GlobalSearchResult.contactAndGroupList: List<GlobalSearchAdapter.Model> get() =
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() {
val unreadThreadMap = messages
@ -428,7 +431,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
override fun onLongConversationClick(thread: ThreadRecord) {
val bottomSheet = ConversationOptionsBottomSheet(this)
bottomSheet.publicKey = publicKey
bottomSheet.thread = thread
bottomSheet.group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull()
bottomSheet.onViewDetailsTapped = {
bottomSheet.dismiss()
val userDetailsBottomSheet = UserDetailsBottomSheet()
@ -588,14 +593,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
// 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)
message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription)
.put(GROUP_NAME_KEY, group.title)
.format()
} else {
// 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)
.put(GROUP_NAME_KEY, group.title)
.format()
@ -622,25 +631,34 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
lifecycleScope.launch(Dispatchers.Main) {
val context = this@HomeActivity
// 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
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 {
GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
GroupUtil.doubleDecodeGroupID(recipient.address.toString())
.toHexString()
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
?.let { MessageSender.explicitLeave(it, false) }
?.let { MessageSender.explicitLeave(it, true, deleteThread = true) }
} 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
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase()
.getOpenGroupChat(threadID)
if (v2OpenGroup != null) {
v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) }
} else {
lifecycleScope.launch(Dispatchers.IO) {
threadDb.deleteConversation(threadID)
}
OpenGroupManager.delete(
v2OpenGroup.server,
v2OpenGroup.room,
context
)
}
// Update the badge count
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)

View File

@ -80,7 +80,7 @@ class HomeViewModel @Inject constructor(
).flowOn(Dispatchers.IO)
private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
.map { threadDb.unapprovedConversationCount }
.map { threadDb.unapprovedConversationList.use { cursor -> cursor.count } }
private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges()
.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.ViewGlobalSearchSubheaderBinding
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.ui.GetString
import java.security.InvalidParameterException
@ -116,7 +116,7 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
fun bind(query: String, model: Model) {
binding.searchResultProfilePicture.recycle()
when (model) {
is Model.GroupConversation -> bindModel(query, model)
is Model.LegacyGroupConversation -> bindModel(query, model)
is Model.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(model)
@ -136,8 +136,9 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
constructor(title: String): this(GetString(title))
}
data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model()
data class GroupConversation(val groupRecord: GroupRecord): Model()
data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model()
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : 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()
}
}

View File

@ -14,7 +14,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
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.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.Message
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.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
}
is GroupConversation -> {
is LegacyGroupConversation -> {
binding.searchResultTitle.text = getHighlight(
query,
model.groupRecord.title
@ -87,9 +87,9 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
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.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup
binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
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 membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
if (model.groupRecord.isClosedGroup) {
if (model.groupRecord.isLegacyClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
}
@ -127,13 +127,6 @@ fun ContentView.bindModel(model: SavedMessages) {
fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
searchResultProfilePicture.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)
searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder()

View File

@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityMessageRequestsBinding
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.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase
@ -80,7 +82,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
override fun onBlockConversationClick(thread: ThreadRecord) {
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)
}
@ -108,7 +113,11 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
showSessionDialog {
title(R.string.delete)
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)
}
}

View File

@ -31,7 +31,9 @@ class MessageRequestsAdapter(
val view = MessageRequestView(context)
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
view.setOnLongClickListener {
view.thread?.let { showPopupMenu(view) }
view.thread?.let { thread ->
showPopupMenu(view, thread.recipient.isGroupRecipient, thread.invitingAdminId)
}
true
}
return ViewHolder(view)
@ -47,10 +49,14 @@ class MessageRequestsAdapter(
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)
// 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.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient
}
popupMenu.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.menu_delete_message_request) {
listener.onDeleteConversationClick(view.thread!!)

View File

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

View File

@ -7,20 +7,24 @@ import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import kotlinx.coroutines.GlobalScope
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind
import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.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.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.libsignal.utilities.Log
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)
if (requestTargets.contains(Targets.DMS)) {
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes ->
val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth)
dmsPromise = SnodeAPI.getMessages(userAuth).bind { envelopes ->
val params = envelopes.map { (envelope, serverHash) ->
// FIXME: Using a job here seems like a bad idea...
MessageReceiveParameters(envelope.toByteArray(), serverHash, null)
}
GlobalScope.asyncPromise {
BatchMessageReceiveJob(params).executeAsync("background")
}
}
promises.add(dmsPromise)
}
// 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 allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) }

View File

@ -43,11 +43,9 @@ import kotlin.concurrent.Volatile
import me.leolin.shortcutbadger.ShortcutBadger
import network.loki.messenger.R
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.utilities.Address.Companion.fromSerialized
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.TextSecurePreferences.Companion.getLocalNumber
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.removeHasHiddenMessageRequests
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Util

View File

@ -6,13 +6,13 @@ import android.content.Context
import android.content.Intent
import android.os.AsyncTask
import androidx.core.app.NotificationManagerCompat
import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared
import org.session.libsession.messaging.messages.control.ReadReceipt
import org.session.libsession.messaging.sending_receiving.MessageSender.send
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeAPI.nowWithOffset
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.associateByNotNull
import org.session.libsession.utilities.recipients.Recipient
@ -102,7 +102,7 @@ class MarkReadReceiver : BroadcastReceiver() {
SnodeAPI.alterTtl(
messageHashes = hashes,
newExpiry = nowWithOffset + expiresIn,
publicKey = TextSecurePreferences.getLocalNumber(context)!!,
auth = checkNotNull(shared.storage.userAuth) { "No authorized user" },
shorten = true
)
}
@ -130,7 +130,7 @@ class MarkReadReceiver : BroadcastReceiver() {
hashToMessage: Map<String, MarkedMessageInfo>
) {
@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) } }
}

View File

@ -1,54 +1,110 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat.getString
import com.goterl.lazysodium.interfaces.AEAD
import com.goterl.lazysodium.utils.Key
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
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.JobQueue
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.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SodiumUtilities
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.BencodeList
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.Log
import org.session.libsignal.utilities.Namespace
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
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 }
fun onPush(dataMap: Map<String, String>?) {
onPush(dataMap?.asByteArray())
}
fun onPush(data: ByteArray?) {
val result = dataMap?.decodeAndDecrypt()
val data = result?.first
if (data == null) {
onPush()
return
}
handlePushData(data = data, metadata = result.second)
}
private fun handlePushData(data: ByteArray, metadata: PushNotificationMetadata?) {
try {
val envelopeAsData = MessageWrapper.unwrap(data).toByteArray()
val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null)
JobQueue.shared.add(job)
val params = when {
metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> {
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) {
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() {
Log.d(TAG, "Failed to decode data for message.")
val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER)
@ -61,10 +117,13 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(11111, builder.build())
}
}
private fun Map<String, String>.asByteArray() =
private fun Map<String, String>.decodeAndDecrypt() =
when {
// this is a v2 push notification
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
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")
val encKey = getOrCreateNotificationKey()
val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES)
val payload =
encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size)
val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce)
?: 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 expectedList = (bencoded.decode() as? BencodeList)?.values
?: 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
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" }
}
} to metadata
}
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
val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
}
return Key.fromHexString(
IdentityKeyUtil.retrieve(
context,
IdentityKeyUtil.NOTIFICATION_KEY
)
)
return 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