mirror of
https://github.com/oxen-io/session-android.git
synced 2025-06-26 05:58:28 +00:00
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:
parent
67bcc937ce
commit
d41496a997
@ -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
10
.drone.yml
Normal 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
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
project.properties
|
||||
.project
|
||||
.settings
|
||||
.kotlin
|
||||
bin/
|
||||
gen/
|
||||
.idea/
|
||||
|
@ -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"
|
||||
|
@ -3,5 +3,8 @@
|
||||
<application>
|
||||
<uses-library android:name="android.test.runner"
|
||||
android:required="false" />
|
||||
|
||||
<activity android:name="androidx.activity.ComponentActivity"/>
|
||||
|
||||
</application>
|
||||
</manifest>
|
@ -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))
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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,11 +148,13 @@ class HomeActivityTests {
|
||||
setupLoggedInState()
|
||||
goToMyChat()
|
||||
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
|
||||
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")
|
||||
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
|
||||
@ -161,7 +164,9 @@ class HomeActivityTests {
|
||||
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
||||
// given the link url text
|
||||
val url = "https://www.ámazon.com"
|
||||
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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"
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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() }
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,7 +44,11 @@ class DisappearingMessages @Inject constructor(
|
||||
|
||||
messageExpirationManager.insertExpirationTimerMessage(message)
|
||||
MessageSender.send(message, address)
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
if (address.isClosedGroupV2) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.from(address))
|
||||
} else {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
|
||||
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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 */ }
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -66,7 +66,7 @@ class StartConversationFragment : BottomSheetDialogFragment(), StartConversation
|
||||
}
|
||||
|
||||
override fun onCreateGroupSelected() {
|
||||
replaceFragment(CreateGroupFragment().also { it.delegate = this })
|
||||
replaceFragment(CreateGroupFragment())
|
||||
}
|
||||
|
||||
override fun onJoinCommunitySelected() {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
if (uiState.isMessageRequestAccepted == true) {
|
||||
binding.messageRequestBar.visibility = View.GONE
|
||||
}
|
||||
if (!uiState.conversationExists && !isFinishing) {
|
||||
// Conversation should be deleted now, just go back
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,49 +991,37 @@ 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 setUpMessageRequestsBar() {
|
||||
binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread()
|
||||
binding.messageRequestBar.isVisible = isIncomingMessageRequestThread()
|
||||
private fun setUpMessageRequests() {
|
||||
binding.acceptMessageRequestButton.setOnClickListener {
|
||||
acceptMessageRequest()
|
||||
viewModel.acceptMessageRequest()
|
||||
}
|
||||
|
||||
binding.messageRequestBlock.setOnClickListener {
|
||||
block(deleteThread = true)
|
||||
}
|
||||
|
||||
binding.declineMessageRequestButton.setOnClickListener {
|
||||
viewModel.declineMessageRequest()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.uiState
|
||||
.map { it.messageRequestState }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { state ->
|
||||
binding.messageRequestBar.isVisible = state != MessageRequestUiState.Invisible
|
||||
|
||||
if (state is MessageRequestUiState.Visible) {
|
||||
binding.sendAcceptsTextView.setText(state.acceptButtonText)
|
||||
binding.messageRequestBlock.isVisible = state.showBlockButton
|
||||
binding.declineMessageRequestButton.setText(state.declineButtonText)
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptMessageRequest() {
|
||||
binding.messageRequestBar.isVisible = false
|
||||
viewModel.acceptMessageRequest()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
@ -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) }
|
||||
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
|
||||
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,
|
||||
|
@ -259,7 +259,6 @@ class VisibleMessageView : FrameLayout {
|
||||
glide,
|
||||
thread,
|
||||
searchQuery,
|
||||
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
|
||||
onAttachmentNeedsDownload
|
||||
)
|
||||
binding.messageContentView.root.delegate = delegate
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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/%'");
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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;";
|
||||
@ -184,31 +196,32 @@ public class RecipientDatabase extends Database {
|
||||
}
|
||||
|
||||
Optional<RecipientSettings> getRecipientSettings(@NonNull Cursor cursor) {
|
||||
boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
|
||||
boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
|
||||
boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
|
||||
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
|
||||
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
|
||||
int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE));
|
||||
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
|
||||
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
|
||||
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
|
||||
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
|
||||
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
|
||||
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
|
||||
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
|
||||
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
|
||||
String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
|
||||
String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
|
||||
String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
|
||||
String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
|
||||
String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
|
||||
String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
|
||||
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR));
|
||||
boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
|
||||
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
|
||||
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
|
||||
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
|
||||
boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
|
||||
boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
|
||||
boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
|
||||
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
|
||||
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
|
||||
int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE));
|
||||
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
|
||||
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));
|
||||
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
|
||||
String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
|
||||
String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
|
||||
String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
|
||||
String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
|
||||
String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
|
||||
String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
|
||||
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR));
|
||||
boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
|
||||
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
|
||||
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
|
||||
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
|
||||
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
|
||||
boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1;
|
||||
|
||||
@ -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);
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 ->
|
||||
|
@ -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 deleteMessagesFrom(long threadId, String fromUser) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
|
||||
}
|
||||
|
||||
void deleteMessagesInThreadBeforeDate(long threadId, long date) {
|
||||
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;
|
||||
}
|
||||
|
||||
where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)");
|
||||
String where = THREAD_ID + " = ? AND " + DATE_SENT + " < " + date;
|
||||
|
||||
db.delete(TABLE_NAME, where, new String[] {threadId + ""});
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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,10 +124,15 @@ 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),
|
||||
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
|
||||
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
|
||||
.toList();
|
||||
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(LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId)
|
||||
)
|
||||
.toList();
|
||||
|
||||
public static String getCreatePinnedCommand() {
|
||||
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
@ -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) {
|
||||
@ -840,12 +811,14 @@ public class ThreadDatabase extends Database {
|
||||
String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ",");
|
||||
String query =
|
||||
"SELECT " + projection + " 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 " + where +
|
||||
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
|
||||
" 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 +
|
||||
" 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";
|
||||
|
||||
if (limit > 0) {
|
||||
query += " LIMIT " + limit;
|
||||
@ -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) {
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
@ -46,39 +49,41 @@ import network.loki.messenger.R;
|
||||
*/
|
||||
public class ThreadRecord extends DisplayRecord {
|
||||
|
||||
private @Nullable final Uri snippetUri;
|
||||
public @Nullable final MessageRecord lastMessage;
|
||||
private final long count;
|
||||
private final int unreadCount;
|
||||
private final int unreadMentionCount;
|
||||
private final int distributionType;
|
||||
private final boolean archived;
|
||||
private final long expiresIn;
|
||||
private final long lastSeen;
|
||||
private final boolean pinned;
|
||||
private final int initialRecipientHash;
|
||||
private final long dateSent;
|
||||
private @Nullable final Uri snippetUri;
|
||||
public @Nullable final MessageRecord lastMessage;
|
||||
private final long count;
|
||||
private final int unreadCount;
|
||||
private final int unreadMentionCount;
|
||||
private final int distributionType;
|
||||
private final boolean archived;
|
||||
private final long expiresIn;
|
||||
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)
|
||||
{
|
||||
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
|
||||
this.snippetUri = snippetUri;
|
||||
this.lastMessage = lastMessage;
|
||||
this.count = count;
|
||||
this.unreadCount = unreadCount;
|
||||
this.unreadMentionCount = unreadMentionCount;
|
||||
this.distributionType = distributionType;
|
||||
this.archived = archived;
|
||||
this.expiresIn = expiresIn;
|
||||
this.lastSeen = lastSeen;
|
||||
this.pinned = pinned;
|
||||
this.initialRecipientHash = recipient.hashCode();
|
||||
this.dateSent = date;
|
||||
}
|
||||
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, String invitingAdminId)
|
||||
{
|
||||
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
|
||||
this.snippetUri = snippetUri;
|
||||
this.lastMessage = lastMessage;
|
||||
this.count = count;
|
||||
this.unreadCount = unreadCount;
|
||||
this.unreadMentionCount = unreadMentionCount;
|
||||
this.distributionType = distributionType;
|
||||
this.archived = archived;
|
||||
this.expiresIn = expiresIn;
|
||||
this.lastSeen = lastSeen;
|
||||
this.pinned = pinned;
|
||||
this.initialRecipientHash = recipient.hashCode();
|
||||
this.invitingAdminId = invitingAdminId;
|
||||
this.dateSent = date;
|
||||
}
|
||||
|
||||
public @Nullable Uri getSnippetUri() {
|
||||
return snippetUri;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
else if (publicKey == userPublicKey) {
|
||||
return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
|
||||
userGroups.getClosedGroup(groupPublicKey) != null
|
||||
} else {
|
||||
userGroups.getLegacyGroupInfo(groupPublicKey) != null
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
|
||||
}
|
@ -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())
|
||||
|
@ -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() }
|
||||
setContent {
|
||||
SessionMaterialTheme {
|
||||
CreateGroupScreen(
|
||||
onNavigateToConversationScreen = { threadID ->
|
||||
startActivity(
|
||||
Intent(requireContext(), ConversationActivityV2::class.java)
|
||||
.putExtra(ConversationActivityV2.THREAD_ID, threadID)
|
||||
)
|
||||
},
|
||||
onBack = delegate::onDialogBackPressed,
|
||||
onClose = delegate::onDialogClosePressed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
_recipients.value = recipients
|
||||
.filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() }
|
||||
}
|
||||
val groupName = groupName.value.trim()
|
||||
if (groupName.isBlank()) {
|
||||
mutableGroupNameError.value = "Group name cannot be empty"
|
||||
return@launch
|
||||
}
|
||||
|
||||
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
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
@ -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
|
||||
}
|
@ -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()
|
||||
},
|
@ -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))) {
|
||||
MessageSender.explicitLeave(groupPublicKey!!, false)
|
||||
} else {
|
||||
task {
|
||||
try {
|
||||
if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
|
||||
MessageSender.explicitLeave(groupPublicKey!!, false)
|
||||
} else {
|
||||
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)
|
@ -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>()
|
@ -0,0 +1,2 @@
|
||||
package org.thoughtcrime.securesms.groups
|
||||
|
@ -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()
|
||||
}
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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 = "",
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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 }
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
|
||||
popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient
|
||||
// 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.setOnMenuItemClickListener { menuItem ->
|
||||
if (menuItem.itemId == R.id.menu_delete_message_request) {
|
||||
listener.onDeleteConversationClick(view.thread!!)
|
||||
|
@ -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,12 +14,10 @@ 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)
|
||||
deleteMessageRequest(thread)
|
||||
}
|
||||
// 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 {
|
||||
|
@ -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)
|
||||
}
|
||||
BatchMessageReceiveJob(params).executeAsync("background")
|
||||
|
||||
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) }
|
||||
|
@ -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
|
||||
|
@ -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) } }
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
NotificationManagerCompat.from(context).notify(11111, builder.build())
|
||||
|
||||
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) {
|
||||
// generate the key and store it
|
||||
val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
|
||||
IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
|
||||
val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY)
|
||||
if (keyHex != null) {
|
||||
return Key.fromHexString(keyHex)
|
||||
}
|
||||
return Key.fromHexString(
|
||||
IdentityKeyUtil.retrieve(
|
||||
context,
|
||||
IdentityKeyUtil.NOTIFICATION_KEY
|
||||
)
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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
Loading…
x
Reference in New Issue
Block a user