mirror of
https://github.com/oxen-io/session-android.git
synced 2025-06-26 14:08:29 +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',
|
pull: 'always',
|
||||||
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
|
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||||
commands: [
|
commands: [
|
||||||
'apt-get install -y ninja-build',
|
'apt-get install -y ninja-build openjdk-17-jdk-headless',
|
||||||
'./gradlew testPlayDebugUnitTestCoverageReport'
|
'./gradlew testPlayDebugUnitTestCoverageReport'
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@ -78,7 +78,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
|
|||||||
pull: 'always',
|
pull: 'always',
|
||||||
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
|
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||||
commands: [
|
commands: [
|
||||||
'apt-get install -y ninja-build',
|
'apt-get install -y ninja-build openjdk-17-jdk-headless',
|
||||||
'./gradlew assemblePlayDebug',
|
'./gradlew assemblePlayDebug',
|
||||||
'./scripts/drone-static-upload.sh'
|
'./scripts/drone-static-upload.sh'
|
||||||
],
|
],
|
||||||
|
10
.drone.yml
Normal file
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.properties
|
||||||
.project
|
.project
|
||||||
.settings
|
.settings
|
||||||
|
.kotlin
|
||||||
bin/
|
bin/
|
||||||
gen/
|
gen/
|
||||||
.idea/
|
.idea/
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
plugins {
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.compose'
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
id 'com.google.dagger.hilt.android'
|
id 'com.google.dagger.hilt.android'
|
||||||
|
id 'kotlin-parcelize'
|
||||||
|
id 'kotlinx-serialization'
|
||||||
}
|
}
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply plugin: 'witness'
|
apply plugin: 'witness'
|
||||||
apply plugin: 'kotlin-parcelize'
|
|
||||||
apply plugin: 'kotlinx-serialization'
|
|
||||||
|
|
||||||
configurations.forEach {
|
configurations.configureEach {
|
||||||
it.exclude module: "commons-logging"
|
exclude module: "commons-logging"
|
||||||
}
|
}
|
||||||
|
|
||||||
def canonicalVersionCode = 380
|
def canonicalVersionCode = 380
|
||||||
@ -40,12 +42,12 @@ android {
|
|||||||
useLibrary 'org.apache.http.legacy'
|
useLibrary 'org.apache.http.legacy'
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = '1.8'
|
jvmTarget = '17'
|
||||||
}
|
}
|
||||||
|
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
@ -54,6 +56,7 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
splits {
|
splits {
|
||||||
abi {
|
abi {
|
||||||
enable true
|
enable true
|
||||||
@ -64,7 +67,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose true
|
viewBinding true
|
||||||
|
buildConfig true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
composeOptions {
|
||||||
@ -151,7 +155,7 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.forEach { variant ->
|
applicationVariants.configureEach { variant ->
|
||||||
variant.outputs.each { output ->
|
variant.outputs.each { output ->
|
||||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||||
def postFix = abiPostFix.get(abiName, 0)
|
def postFix = abiPostFix.get(abiName, 0)
|
||||||
@ -169,11 +173,11 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding true
|
|
||||||
}
|
|
||||||
|
|
||||||
def huaweiEnabled = project.properties['huawei'] != null
|
def huaweiEnabled = project.properties['huawei'] != null
|
||||||
|
lint {
|
||||||
|
abortOnError true
|
||||||
|
baseline file('lint-baseline.xml')
|
||||||
|
}
|
||||||
|
|
||||||
applicationVariants.configureEach { variant ->
|
applicationVariants.configureEach { variant ->
|
||||||
if (variant.flavorName == 'huawei') {
|
if (variant.flavorName == 'huawei') {
|
||||||
@ -226,6 +230,7 @@ dependencies {
|
|||||||
ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion")
|
ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion")
|
||||||
ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion")
|
ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion")
|
||||||
ksp("com.github.bumptech.glide:ksp:$glideVersion")
|
ksp("com.github.bumptech.glide:ksp:$glideVersion")
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:$androidxHiltVersion")
|
||||||
|
|
||||||
implementation("com.google.dagger:hilt-android:$daggerHiltVersion")
|
implementation("com.google.dagger:hilt-android:$daggerHiltVersion")
|
||||||
implementation "androidx.appcompat:appcompat:$appcompatVersion"
|
implementation "androidx.appcompat:appcompat:$appcompatVersion"
|
||||||
@ -305,7 +310,6 @@ dependencies {
|
|||||||
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
||||||
implementation "com.squareup.phrase:phrase:$phraseVersion"
|
implementation "com.squareup.phrase:phrase:$phraseVersion"
|
||||||
implementation 'app.cash.copper:copper-flow:1.0.0'
|
implementation 'app.cash.copper:copper-flow:1.0.0'
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
|
||||||
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
||||||
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
|
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
|
||||||
@ -329,7 +333,6 @@ dependencies {
|
|||||||
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
|
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
|
||||||
exclude group: 'org.jetbrains.kotlin'
|
exclude group: 'org.jetbrains.kotlin'
|
||||||
}
|
}
|
||||||
|
|
||||||
// AndroidJUnitRunner and JUnit Rules
|
// AndroidJUnitRunner and JUnit Rules
|
||||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||||
@ -348,6 +351,8 @@ dependencies {
|
|||||||
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
|
||||||
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
|
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||||
|
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3"
|
||||||
|
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3"
|
||||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||||
|
|
||||||
testImplementation 'org.robolectric:robolectric:4.12.2'
|
testImplementation 'org.robolectric:robolectric:4.12.2'
|
||||||
@ -365,6 +370,11 @@ dependencies {
|
|||||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion"
|
androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion"
|
||||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
|
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
|
||||||
|
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
|
||||||
|
implementation "androidx.navigation:navigation-compose:$navVersion"
|
||||||
|
|
||||||
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
|
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
|
||||||
implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
|
implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
|
||||||
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
|
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
|
||||||
|
@ -3,5 +3,8 @@
|
|||||||
<application>
|
<application>
|
||||||
<uses-library android:name="android.test.runner"
|
<uses-library android:name="android.test.runner"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
|
<activity android:name="androidx.activity.ComponentActivity"/>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
@ -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
|
package network.loki.messenger
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Instrumentation
|
import android.app.Instrumentation
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
import androidx.test.espresso.Espresso.onView
|
import androidx.test.espresso.Espresso.onView
|
||||||
import androidx.test.espresso.Espresso.pressBack
|
import androidx.test.espresso.Espresso.pressBack
|
||||||
import androidx.test.espresso.UiController
|
|
||||||
import androidx.test.espresso.ViewAction
|
|
||||||
import androidx.test.espresso.action.ViewActions
|
import androidx.test.espresso.action.ViewActions
|
||||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||||
import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed
|
import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed
|
||||||
@ -16,14 +15,15 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot
|
|||||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.LargeTest
|
import androidx.test.filters.SmallTest
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import network.loki.messenger.util.sendMessage
|
||||||
|
import network.loki.messenger.util.waitFor
|
||||||
import androidx.test.uiautomator.By
|
import androidx.test.uiautomator.By
|
||||||
import androidx.test.uiautomator.UiDevice
|
import androidx.test.uiautomator.UiDevice
|
||||||
import com.adevinta.android.barista.interaction.PermissionGranter
|
import com.adevinta.android.barista.interaction.PermissionGranter
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
|
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
|
||||||
import org.hamcrest.Matcher
|
|
||||||
import org.hamcrest.Matchers.allOf
|
import org.hamcrest.Matchers.allOf
|
||||||
import org.hamcrest.Matchers.not
|
import org.hamcrest.Matchers.not
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.home.HomeActivity
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@LargeTest
|
@SmallTest
|
||||||
class HomeActivityTests {
|
class HomeActivityTests {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
@ -108,6 +108,7 @@ class HomeActivityTests {
|
|||||||
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
|
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
|
||||||
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
||||||
// new chat
|
// new chat
|
||||||
|
Thread.sleep(500)
|
||||||
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
|
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
|
||||||
onView(withId(R.id.copyButton)).perform(ViewActions.click())
|
onView(withId(R.id.copyButton)).perform(ViewActions.click())
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
@ -147,12 +148,14 @@ class HomeActivityTests {
|
|||||||
setupLoggedInState()
|
setupLoggedInState()
|
||||||
goToMyChat()
|
goToMyChat()
|
||||||
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
|
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
|
||||||
|
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
|
||||||
sendMessage("howdy")
|
sendMessage("howdy")
|
||||||
sendMessage("test")
|
sendMessage("test")
|
||||||
// tests url rewriter doesn't crash
|
// tests url rewriter doesn't crash
|
||||||
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
|
sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
|
||||||
sendMessage("https://www.ámazon.com")
|
sendMessage("https://www.ámazon.com")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testChat_displaysCorrectUrl() {
|
fun testChat_displaysCorrectUrl() {
|
||||||
@ -161,7 +164,9 @@ class HomeActivityTests {
|
|||||||
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
|
||||||
// given the link url text
|
// given the link url text
|
||||||
val url = "https://www.ámazon.com"
|
val url = "https://www.ámazon.com"
|
||||||
|
with (activityMonitor.waitForActivity() as ConversationActivityV2) {
|
||||||
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
|
sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
|
||||||
|
}
|
||||||
|
|
||||||
// when the URL span is clicked
|
// when the URL span is clicked
|
||||||
onView(withSubstring(url)).perform(ViewActions.click())
|
onView(withSubstring(url)).perform(ViewActions.click())
|
||||||
@ -175,21 +180,4 @@ class HomeActivityTests {
|
|||||||
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform action of waiting for a specific time.
|
|
||||||
*/
|
|
||||||
fun waitFor(millis: Long): ViewAction {
|
|
||||||
return object : ViewAction {
|
|
||||||
override fun getConstraints(): Matcher<View>? {
|
|
||||||
return isRoot()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDescription(): String = "Wait for $millis milliseconds."
|
|
||||||
|
|
||||||
override fun perform(uiController: UiController, view: View?) {
|
|
||||||
uiController.loopMainThreadForAtLeast(millis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -11,6 +11,10 @@ import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
|||||||
import network.loki.messenger.libsession_util.util.Contact
|
import network.loki.messenger.libsession_util.util.Contact
|
||||||
import network.loki.messenger.libsession_util.util.Conversation
|
import network.loki.messenger.libsession_util.util.Conversation
|
||||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||||
|
import network.loki.messenger.util.applySpiedStorage
|
||||||
|
import network.loki.messenger.util.maybeGetUserInfo
|
||||||
|
import network.loki.messenger.util.randomSeedBytes
|
||||||
|
import network.loki.messenger.util.randomSessionId
|
||||||
import org.hamcrest.CoreMatchers.equalTo
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.hamcrest.CoreMatchers.instanceOf
|
import org.hamcrest.CoreMatchers.instanceOf
|
||||||
import org.hamcrest.MatcherAssert.assertThat
|
import org.hamcrest.MatcherAssert.assertThat
|
||||||
@ -31,32 +35,15 @@ import org.session.libsignal.utilities.KeyHelper
|
|||||||
import org.session.libsignal.utilities.hexEncodedPublicKey
|
import org.session.libsignal.utilities.hexEncodedPublicKey
|
||||||
import org.thoughtcrime.securesms.ApplicationContext
|
import org.thoughtcrime.securesms.ApplicationContext
|
||||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@SmallTest
|
@SmallTest
|
||||||
class LibSessionTests {
|
class LibSessionTests {
|
||||||
|
|
||||||
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
|
|
||||||
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
|
|
||||||
private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
|
|
||||||
|
|
||||||
private var fakeHashI = 0
|
private var fakeHashI = 0
|
||||||
private val nextFakeHash: String
|
private val nextFakeHash: String
|
||||||
get() = "fakehash${fakeHashI++}"
|
get() = "fakehash${fakeHashI++}"
|
||||||
|
|
||||||
private fun maybeGetUserInfo(): Pair<ByteArray, String>? {
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
|
||||||
val prefs = appContext.prefs
|
|
||||||
val localUserPublicKey = prefs.getLocalNumber()
|
|
||||||
val secretKey = with(appContext) {
|
|
||||||
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
|
|
||||||
edKey.secretKey.asBytes
|
|
||||||
}
|
|
||||||
return if (localUserPublicKey == null || secretKey == null) null
|
|
||||||
else secretKey to localUserPublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
|
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
|
||||||
val (key,_) = maybeGetUserInfo()!!
|
val (key,_) = maybeGetUserInfo()!!
|
||||||
val contacts = Contacts.newInstance(key)
|
val contacts = Contacts.newInstance(key)
|
||||||
@ -98,11 +85,10 @@ class LibSessionTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun migration_one_to_ones() {
|
fun migration_one_to_ones() {
|
||||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||||
val storageSpy = spy(app.storage)
|
val storage = applicationContext.applySpiedStorage()
|
||||||
app.storage = storageSpy
|
|
||||||
|
|
||||||
val newContactId = randomAccountId()
|
val newContactId = randomSessionId()
|
||||||
val singleContact = Contact(
|
val singleContact = Contact(
|
||||||
id = newContactId,
|
id = newContactId,
|
||||||
approved = true,
|
approved = true,
|
||||||
@ -111,10 +97,10 @@ class LibSessionTests {
|
|||||||
val newContactMerge = buildContactMessage(listOf(singleContact))
|
val newContactMerge = buildContactMessage(listOf(singleContact))
|
||||||
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
||||||
fakePollNewConfig(contacts, newContactMerge)
|
fakePollNewConfig(contacts, newContactMerge)
|
||||||
verify(storageSpy).addLibSessionContacts(argThat {
|
verify(storage).addLibSessionContacts(argThat {
|
||||||
first().let { it.id == newContactId && it.approved } && size == 1
|
first().let { it.id == newContactId && it.approved } && size == 1
|
||||||
}, any())
|
}, any())
|
||||||
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
|
verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -123,7 +109,7 @@ class LibSessionTests {
|
|||||||
val storageSpy = spy(app.storage)
|
val storageSpy = spy(app.storage)
|
||||||
app.storage = storageSpy
|
app.storage = storageSpy
|
||||||
|
|
||||||
val randomRecipient = randomAccountId()
|
val randomRecipient = randomSessionId()
|
||||||
val newContact = Contact(
|
val newContact = Contact(
|
||||||
id = randomRecipient,
|
id = randomRecipient,
|
||||||
approved = true,
|
approved = true,
|
||||||
@ -158,7 +144,7 @@ class LibSessionTests {
|
|||||||
app.storage = storageSpy
|
app.storage = storageSpy
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
val randomRecipient = randomAccountId()
|
val randomRecipient = randomSessionId()
|
||||||
val currentContact = Contact(
|
val currentContact = Contact(
|
||||||
id = randomRecipient,
|
id = randomRecipient,
|
||||||
approved = true,
|
approved = true,
|
||||||
|
@ -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"
|
android:label="@string/conversationsBlockedContacts"
|
||||||
/>
|
/>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"
|
android:name="org.thoughtcrime.securesms.groups.EditLegacyGroupActivity"
|
||||||
|
android:label="@string/groupEdit"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="org.thoughtcrime.securesms.groups.EditGroupActivity"
|
||||||
|
android:theme="@style/Theme.Session.DayNight.NoActionBar"
|
||||||
android:label="@string/groupEdit"
|
android:label="@string/groupEdit"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
<activity
|
<activity
|
||||||
@ -237,7 +243,13 @@
|
|||||||
android:name="android.support.PARENT_ACTIVITY"
|
android:name="android.support.PARENT_ACTIVITY"
|
||||||
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivity"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@style/Theme.Session.DayNight.NoActionBar"/>
|
||||||
|
<activity android:name="org.thoughtcrime.securesms.conversation.settings.ConversationNotificationSettingsActivity"
|
||||||
|
android:label="@string/sessionNotifications"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@style/Theme.Session.DayNight"/>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
|
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
|
@ -25,8 +25,10 @@ import android.content.Intent;
|
|||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.HandlerThread;
|
import android.os.HandlerThread;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.StringRes;
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat;
|
import androidx.core.content.pm.ShortcutInfoCompat;
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat;
|
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||||
import androidx.core.graphics.drawable.IconCompat;
|
import androidx.core.graphics.drawable.IconCompat;
|
||||||
@ -34,12 +36,16 @@ import androidx.lifecycle.DefaultLifecycleObserver;
|
|||||||
import androidx.lifecycle.LifecycleOwner;
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner;
|
import androidx.lifecycle.ProcessLifecycleOwner;
|
||||||
|
|
||||||
|
import com.squareup.phrase.Phrase;
|
||||||
|
|
||||||
import org.conscrypt.Conscrypt;
|
import org.conscrypt.Conscrypt;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.session.libsession.avatars.AvatarHelper;
|
import org.session.libsession.avatars.AvatarHelper;
|
||||||
import org.session.libsession.database.MessageDataProvider;
|
import org.session.libsession.database.MessageDataProvider;
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration;
|
import org.session.libsession.messaging.MessagingModuleConfiguration;
|
||||||
|
import org.session.libsession.messaging.notifications.TokenFetcher;
|
||||||
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
|
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
|
||||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2;
|
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2;
|
||||||
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
|
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
|
||||||
import org.session.libsession.snode.SnodeModule;
|
import org.session.libsession.snode.SnodeModule;
|
||||||
import org.session.libsession.utilities.Address;
|
import org.session.libsession.utilities.Address;
|
||||||
@ -49,6 +55,7 @@ import org.session.libsession.utilities.Environment;
|
|||||||
import org.session.libsession.utilities.ProfilePictureUtilities;
|
import org.session.libsession.utilities.ProfilePictureUtilities;
|
||||||
import org.session.libsession.utilities.SSKEnvironment;
|
import org.session.libsession.utilities.SSKEnvironment;
|
||||||
import org.session.libsession.utilities.TextSecurePreferences;
|
import org.session.libsession.utilities.TextSecurePreferences;
|
||||||
|
import org.session.libsession.utilities.Toaster;
|
||||||
import org.session.libsession.utilities.Util;
|
import org.session.libsession.utilities.Util;
|
||||||
import org.session.libsession.utilities.WindowDebouncer;
|
import org.session.libsession.utilities.WindowDebouncer;
|
||||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||||
@ -59,7 +66,6 @@ import org.session.libsignal.utilities.Log;
|
|||||||
import org.session.libsignal.utilities.ThreadUtils;
|
import org.session.libsignal.utilities.ThreadUtils;
|
||||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
|
||||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||||
import org.thoughtcrime.securesms.database.LastSentTimestampCache;
|
import org.thoughtcrime.securesms.database.LastSentTimestampCache;
|
||||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
||||||
@ -71,6 +77,7 @@ import org.thoughtcrime.securesms.dependencies.AppComponent;
|
|||||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
|
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
||||||
|
import org.thoughtcrime.securesms.dependencies.PollerFactory;
|
||||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||||
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
||||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||||
@ -80,9 +87,9 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
|
|||||||
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
|
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
|
||||||
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
|
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
|
||||||
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
|
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
|
||||||
|
import org.thoughtcrime.securesms.notifications.PushRegistrationHandler;
|
||||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||||
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
|
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
|
||||||
import org.thoughtcrime.securesms.notifications.PushRegistry;
|
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||||
@ -103,6 +110,7 @@ import java.security.Security;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Timer;
|
import java.util.Timer;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
@ -113,7 +121,7 @@ import dagger.hilt.android.HiltAndroidApp;
|
|||||||
import kotlin.Unit;
|
import kotlin.Unit;
|
||||||
import network.loki.messenger.BuildConfig;
|
import network.loki.messenger.BuildConfig;
|
||||||
import network.loki.messenger.R;
|
import network.loki.messenger.R;
|
||||||
import network.loki.messenger.libsession_util.ConfigBase;
|
import network.loki.messenger.libsession_util.Config;
|
||||||
import network.loki.messenger.libsession_util.UserProfile;
|
import network.loki.messenger.libsession_util.UserProfile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,7 +133,7 @@ import network.loki.messenger.libsession_util.UserProfile;
|
|||||||
* @author Moxie Marlinspike
|
* @author Moxie Marlinspike
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener {
|
public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener, Toaster {
|
||||||
|
|
||||||
public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
|
public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
|
||||||
|
|
||||||
@ -149,10 +157,13 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
@Inject Device device;
|
@Inject Device device;
|
||||||
@Inject MessageDataProvider messageDataProvider;
|
@Inject MessageDataProvider messageDataProvider;
|
||||||
@Inject TextSecurePreferences textSecurePreferences;
|
@Inject TextSecurePreferences textSecurePreferences;
|
||||||
@Inject PushRegistry pushRegistry;
|
|
||||||
@Inject ConfigFactory configFactory;
|
@Inject ConfigFactory configFactory;
|
||||||
|
@Inject PollerFactory pollerFactory;
|
||||||
@Inject LastSentTimestampCache lastSentTimestampCache;
|
@Inject LastSentTimestampCache lastSentTimestampCache;
|
||||||
@Inject VersionDataFetcher versionDataFetcher;
|
@Inject VersionDataFetcher versionDataFetcher;
|
||||||
|
@Inject
|
||||||
|
PushRegistrationHandler pushRegistrationHandler;
|
||||||
|
@Inject TokenFetcher tokenFetcher;
|
||||||
CallMessageProcessor callMessageProcessor;
|
CallMessageProcessor callMessageProcessor;
|
||||||
MessagingModuleConfiguration messagingModuleConfiguration;
|
MessagingModuleConfiguration messagingModuleConfiguration;
|
||||||
|
|
||||||
@ -201,7 +212,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
|
public void notifyUpdates(@NotNull Config forConfigObject, long messageTimestamp) {
|
||||||
// forward to the config factory / storage ig
|
// forward to the config factory / storage ig
|
||||||
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
|
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
|
||||||
textSecurePreferences.setConfigurationMessageSynced(true);
|
textSecurePreferences.setConfigurationMessageSynced(true);
|
||||||
@ -209,6 +220,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
|
storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void toast(@StringRes int stringRes, int toastLength, @NonNull Map<String, String> parameters) {
|
||||||
|
Phrase builder = Phrase.from(this, stringRes);
|
||||||
|
for (Map.Entry<String,String> entry : parameters.entrySet()) {
|
||||||
|
builder.put(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
Toast.makeText(getApplicationContext(), builder.format(), toastLength).show();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
|
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
|
||||||
@ -222,9 +242,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
storage,
|
storage,
|
||||||
device,
|
device,
|
||||||
messageDataProvider,
|
messageDataProvider,
|
||||||
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
|
|
||||||
configFactory,
|
configFactory,
|
||||||
lastSentTimestampCache
|
lastSentTimestampCache,
|
||||||
|
this,
|
||||||
|
tokenFetcher
|
||||||
);
|
);
|
||||||
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
|
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
|
||||||
Log.i(TAG, "onCreate()");
|
Log.i(TAG, "onCreate()");
|
||||||
@ -256,6 +277,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
|
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
|
||||||
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
|
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
|
||||||
|
|
||||||
|
pushRegistrationHandler.run();
|
||||||
|
|
||||||
// add our shortcut debug menu if we are not in a release build
|
// add our shortcut debug menu if we are not in a release build
|
||||||
if (BuildConfig.BUILD_TYPE != "release") {
|
if (BuildConfig.BUILD_TYPE != "release") {
|
||||||
// add the config settings shortcut
|
// add the config settings shortcut
|
||||||
@ -308,7 +331,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
if (poller != null) {
|
if (poller != null) {
|
||||||
poller.stopIfNeeded();
|
poller.stopIfNeeded();
|
||||||
}
|
}
|
||||||
ClosedGroupPollerV2.getShared().stopAll();
|
pollerFactory.stopAll();
|
||||||
|
LegacyClosedGroupPollerV2.getShared().stopAll();
|
||||||
versionDataFetcher.stopTimedVersionCheck();
|
versionDataFetcher.stopTimedVersionCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,6 +340,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
public void onTerminate() {
|
public void onTerminate() {
|
||||||
stopKovenant(); // Loki
|
stopKovenant(); // Loki
|
||||||
OpenGroupManager.INSTANCE.stopPolling();
|
OpenGroupManager.INSTANCE.stopPolling();
|
||||||
|
pollerFactory.stopAll();
|
||||||
versionDataFetcher.stopTimedVersionCheck();
|
versionDataFetcher.stopTimedVersionCheck();
|
||||||
super.onTerminate();
|
super.onTerminate();
|
||||||
}
|
}
|
||||||
@ -438,7 +463,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
poller.setUserPublicKey(userPublicKey);
|
poller.setUserPublicKey(userPublicKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
poller = new Poller(configFactory, new Timer());
|
poller = new Poller(configFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void startPollingIfNeeded() {
|
public void startPollingIfNeeded() {
|
||||||
@ -446,7 +471,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
if (poller != null) {
|
if (poller != null) {
|
||||||
poller.startIfNeeded();
|
poller.startIfNeeded();
|
||||||
}
|
}
|
||||||
ClosedGroupPollerV2.getShared().start();
|
pollerFactory.startAll();
|
||||||
|
LegacyClosedGroupPollerV2.getShared().start();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void retrieveUserProfile() {
|
public void retrieveUserProfile() {
|
||||||
|
@ -405,6 +405,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
@SuppressWarnings("CodeBlock2Expr")
|
@SuppressWarnings("CodeBlock2Expr")
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
private void saveToDisk() {
|
private void saveToDisk() {
|
||||||
|
Log.w("ACL", "Asked to save to disk!");
|
||||||
MediaItem mediaItem = getCurrentMediaItem();
|
MediaItem mediaItem = getCurrentMediaItem();
|
||||||
if (mediaItem == null) return;
|
if (mediaItem == null) return;
|
||||||
|
|
||||||
|
@ -142,11 +142,11 @@ class SessionDialogBuilder(val context: Context) {
|
|||||||
|
|
||||||
fun dangerButton(
|
fun dangerButton(
|
||||||
@StringRes text: Int,
|
@StringRes text: Int,
|
||||||
@StringRes contentDescription: Int = text,
|
@StringRes contentDescriptionRes: Int = text,
|
||||||
listener: () -> Unit = {}
|
listener: () -> Unit = {}
|
||||||
) = button(
|
) = button(
|
||||||
text,
|
text,
|
||||||
contentDescription,
|
contentDescriptionRes,
|
||||||
R.style.Widget_Session_Button_Dialog_DangerText,
|
R.style.Widget_Session_Button_Dialog_DangerText,
|
||||||
) { listener() }
|
) { listener() }
|
||||||
|
|
||||||
|
@ -215,13 +215,29 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
|||||||
threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) }
|
threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateMessageAsDeleted(messageId: Long, isSms: Boolean): Long {
|
||||||
|
val messagingDatabase: MessagingDatabase =
|
||||||
|
if (isSms) DatabaseComponent.get(context).smsDatabase()
|
||||||
|
else DatabaseComponent.get(context).mmsDatabase()
|
||||||
|
|
||||||
|
val isOutgoing = messagingDatabase.isOutgoing(messageId)
|
||||||
|
messagingDatabase.markAsDeleted(messageId)
|
||||||
|
|
||||||
|
if (isOutgoing) {
|
||||||
|
messagingDatabase.deleteMessage(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageId
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
|
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
|
||||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||||
val address = Address.fromSerialized(author)
|
val address = Address.fromSerialized(author)
|
||||||
val message = database.getMessageFor(timestamp, address) ?: return null
|
val message = database.getMessageFor(timestamp, address) ?: return null
|
||||||
|
updateMessageAsDeleted(message.id, !message.isMms)
|
||||||
val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase()
|
val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase()
|
||||||
else DatabaseComponent.get(context).smsDatabase()
|
else DatabaseComponent.get(context).smsDatabase()
|
||||||
messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention)
|
messagingDatabase.markAsDeleted(message.id)
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
messagingDatabase.deleteMessage(message.id)
|
messagingDatabase.deleteMessage(message.id)
|
||||||
}
|
}
|
||||||
|
@ -51,19 +51,19 @@ class ProfilePictureView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun update(recipient: Recipient) {
|
fun update(recipient: Recipient) {
|
||||||
recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) }
|
recipient.run { update(address, isLegacyClosedGroupRecipient, isOpenGroupInboxRecipient) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(
|
fun update(
|
||||||
address: Address,
|
address: Address,
|
||||||
isClosedGroupRecipient: Boolean = false,
|
isLegacyClosedGroupRecipient: Boolean = false,
|
||||||
isOpenGroupInboxRecipient: Boolean = false
|
isOpenGroupInboxRecipient: Boolean = false
|
||||||
) {
|
) {
|
||||||
fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName()
|
fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName()
|
||||||
?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR)
|
?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR)
|
||||||
?: publicKey
|
?: publicKey
|
||||||
|
|
||||||
if (isClosedGroupRecipient) {
|
if (isLegacyClosedGroupRecipient) {
|
||||||
val members = DatabaseComponent.get(context).groupDatabase()
|
val members = DatabaseComponent.get(context).groupDatabase()
|
||||||
.getGroupMemberAddresses(address.toGroupString(), true)
|
.getGroupMemberAddresses(address.toGroupString(), true)
|
||||||
.sorted()
|
.sorted()
|
||||||
|
@ -8,7 +8,6 @@ import android.widget.ImageView
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.widget.ImageViewCompat
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
@ -52,7 +52,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
|
|||||||
|
|
||||||
private fun getClosedGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
|
private fun getClosedGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
|
||||||
return getItems(contacts, context.getString(R.string.conversationsGroups)) {
|
return getItems(contacts, context.getString(R.string.conversationsGroups)) {
|
||||||
it.address.isClosedGroup
|
it.address.isLegacyClosedGroup || it.address.isClosedGroupV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,7 +13,6 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
|||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.squareup.phrase.Phrase
|
import com.squareup.phrase.Phrase
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewConversationActionBarBinding
|
import network.loki.messenger.databinding.ViewConversationActionBarBinding
|
||||||
@ -31,6 +30,8 @@ import org.session.libsession.utilities.recipients.Recipient
|
|||||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||||
|
import org.thoughtcrime.securesms.database.Storage
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ConversationActionBarView @JvmOverloads constructor(
|
class ConversationActionBarView @JvmOverloads constructor(
|
||||||
@ -42,6 +43,7 @@ class ConversationActionBarView @JvmOverloads constructor(
|
|||||||
|
|
||||||
@Inject lateinit var lokiApiDb: LokiAPIDatabase
|
@Inject lateinit var lokiApiDb: LokiAPIDatabase
|
||||||
@Inject lateinit var groupDb: GroupDatabase
|
@Inject lateinit var groupDb: GroupDatabase
|
||||||
|
@Inject lateinit var storage: Storage
|
||||||
|
|
||||||
var delegate: ConversationActionBarDelegate? = null
|
var delegate: ConversationActionBarDelegate? = null
|
||||||
|
|
||||||
@ -51,6 +53,9 @@ class ConversationActionBarView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val profilePictureView
|
||||||
|
get() = binding.profilePictureView
|
||||||
|
|
||||||
init {
|
init {
|
||||||
var previousState: Int
|
var previousState: Int
|
||||||
var currentState = 0
|
var currentState = 0
|
||||||
@ -80,7 +85,7 @@ class ConversationActionBarView @JvmOverloads constructor(
|
|||||||
) {
|
) {
|
||||||
this.delegate = delegate
|
this.delegate = delegate
|
||||||
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
|
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
|
||||||
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
|
if (recipient.isClosedGroupV2Recipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
|
||||||
).let { LayoutParams(it, it) }
|
).let { LayoutParams(it, it) }
|
||||||
update(recipient, openGroup, config)
|
update(recipient, openGroup, config)
|
||||||
}
|
}
|
||||||
@ -141,7 +146,11 @@ class ConversationActionBarView @JvmOverloads constructor(
|
|||||||
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
|
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
|
||||||
resources.getQuantityString(R.plurals.membersActive, userCount, userCount)
|
resources.getQuantityString(R.plurals.membersActive, userCount, userCount)
|
||||||
} else {
|
} else {
|
||||||
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
|
val userCount = if (recipient.isClosedGroupV2Recipient) {
|
||||||
|
storage.getMembers(recipient.address.serialize()).size
|
||||||
|
} else { // legacy closed groups
|
||||||
|
groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
|
||||||
|
}
|
||||||
resources.getQuantityString(R.plurals.members, userCount, userCount)
|
resources.getQuantityString(R.plurals.members, userCount, userCount)
|
||||||
}
|
}
|
||||||
settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT)
|
settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT)
|
||||||
|
@ -5,6 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
|
import org.session.libsession.messaging.messages.Destination
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||||
@ -43,8 +44,12 @@ class DisappearingMessages @Inject constructor(
|
|||||||
|
|
||||||
messageExpirationManager.insertExpirationTimerMessage(message)
|
messageExpirationManager.insertExpirationTimerMessage(message)
|
||||||
MessageSender.send(message, address)
|
MessageSender.send(message, address)
|
||||||
|
if (address.isClosedGroupV2) {
|
||||||
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.from(address))
|
||||||
|
} else {
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
|
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
|
||||||
title(R.string.disappearingMessagesFollowSetting)
|
title(R.string.disappearingMessagesFollowSetting)
|
||||||
@ -58,9 +63,9 @@ class DisappearingMessages @Inject constructor(
|
|||||||
|
|
||||||
dangerButton(
|
dangerButton(
|
||||||
text = if (message.expiresIn == 0L) R.string.confirm else R.string.set,
|
text = if (message.expiresIn == 0L) R.string.confirm else R.string.set,
|
||||||
contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton
|
contentDescriptionRes = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton
|
||||||
) {
|
) {
|
||||||
set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient)
|
set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupV2Recipient)
|
||||||
}
|
}
|
||||||
cancelButton()
|
cancelButton()
|
||||||
}
|
}
|
||||||
|
@ -59,16 +59,28 @@ class DisappearingMessagesViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
|
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
|
||||||
val recipient = threadDb.getRecipientForThreadId(threadId)
|
val recipient = threadDb.getRecipientForThreadId(threadId) ?: return@launch
|
||||||
val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient }
|
val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient }
|
||||||
?.run { groupDb.getGroup(address.toGroupString()).orNull() }
|
?.run { groupDb.getGroup(address.toGroupString()).orNull() }
|
||||||
|
|
||||||
|
val isAdmin = when {
|
||||||
|
recipient.isClosedGroupV2Recipient -> {
|
||||||
|
// Handle the new closed group functionality
|
||||||
|
storage.getMembers(recipient.address.serialize()).any { it.sessionId == textSecurePreferences.getLocalNumber() && it.admin }
|
||||||
|
}
|
||||||
|
recipient.isLegacyClosedGroupRecipient -> {
|
||||||
|
// Handle as legacy group
|
||||||
|
groupRecord?.admins?.any{ it.serialize() == textSecurePreferences.getLocalNumber() } == true
|
||||||
|
}
|
||||||
|
else -> !recipient.isGroupRecipient
|
||||||
|
}
|
||||||
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
address = recipient?.address,
|
address = recipient.address,
|
||||||
isGroup = groupRecord != null,
|
isGroup = recipient.isGroupRecipient,
|
||||||
isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(),
|
isNoteToSelf = recipient.address.serialize() == textSecurePreferences.getLocalNumber(),
|
||||||
isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() },
|
isSelfAdmin = isAdmin,
|
||||||
expiryMode = expiryMode,
|
expiryMode = expiryMode,
|
||||||
persistedMode = expiryMode
|
persistedMode = expiryMode
|
||||||
)
|
)
|
||||||
|
@ -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() {
|
override fun onCreateGroupSelected() {
|
||||||
replaceFragment(CreateGroupFragment().also { it.delegate = this })
|
replaceFragment(CreateGroupFragment())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onJoinCommunitySelected() {
|
override fun onJoinCommunitySelected() {
|
||||||
|
@ -23,6 +23,7 @@ import network.loki.messenger.R
|
|||||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||||
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
|
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
|
||||||
import org.thoughtcrime.securesms.ui.components.BackAppBar
|
import org.thoughtcrime.securesms.ui.components.BackAppBar
|
||||||
|
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||||
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
|
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
|
||||||
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
|
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
|
||||||
import org.thoughtcrime.securesms.ui.components.border
|
import org.thoughtcrime.securesms.ui.components.border
|
||||||
|
@ -37,6 +37,8 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@ -142,8 +144,10 @@ private fun EnterAccountId(
|
|||||||
SessionOutlinedTextField(
|
SessionOutlinedTextField(
|
||||||
text = state.newMessageIdOrOns,
|
text = state.newMessageIdOrOns,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = LocalDimensions.current.spacing),
|
.padding(horizontal = LocalDimensions.current.spacing)
|
||||||
contentDescription = "Session id input box",
|
.semantics {
|
||||||
|
contentDescription = "Session id input box"
|
||||||
|
},
|
||||||
placeholder = stringResource(R.string.accountIdOrOnsEnter),
|
placeholder = stringResource(R.string.accountIdOrOnsEnter),
|
||||||
onChange = callbacks::onChange,
|
onChange = callbacks::onChange,
|
||||||
onContinue = callbacks::onContinue,
|
onContinue = callbacks::onContinue,
|
||||||
|
@ -59,14 +59,23 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ActivityConversationV2Binding
|
import network.loki.messenger.databinding.ActivityConversationV2Binding
|
||||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||||
import nl.komponents.kovenant.ui.successUi
|
import nl.komponents.kovenant.ui.successUi
|
||||||
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.messages.applyExpiryMode
|
import org.session.libsession.messaging.messages.applyExpiryMode
|
||||||
@ -80,7 +89,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
|
|||||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||||
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
||||||
import org.session.libsession.messaging.utilities.AccountId
|
|
||||||
import org.session.libsession.snode.OnionRequestAPI
|
import org.session.libsession.snode.OnionRequestAPI
|
||||||
import org.session.libsession.snode.SnodeAPI
|
import org.session.libsession.snode.SnodeAPI
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
@ -100,6 +108,7 @@ import org.session.libsignal.crypto.MnemonicCodec
|
|||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.session.libsignal.utilities.ListenableFuture
|
import org.session.libsignal.utilities.ListenableFuture
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.session.libsignal.utilities.guava.Optional
|
import org.session.libsignal.utilities.guava.Optional
|
||||||
import org.session.libsignal.utilities.hexEncodedPrivateKey
|
import org.session.libsignal.utilities.hexEncodedPrivateKey
|
||||||
import org.thoughtcrime.securesms.ApplicationContext
|
import org.thoughtcrime.securesms.ApplicationContext
|
||||||
@ -111,6 +120,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
|||||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
|
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
|
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
|
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
|
||||||
|
import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityContract
|
||||||
|
import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityResult
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
|
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
|
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
|
||||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
|
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
|
||||||
@ -147,7 +158,6 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
|||||||
import org.thoughtcrime.securesms.database.ReactionDatabase
|
import org.thoughtcrime.securesms.database.ReactionDatabase
|
||||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
import org.thoughtcrime.securesms.database.model.MessageId
|
import org.thoughtcrime.securesms.database.model.MessageId
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
@ -155,6 +165,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
|||||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||||
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
|
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
|
||||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||||
|
import org.thoughtcrime.securesms.home.search.getSearchName
|
||||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
|
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
|
||||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
|
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
|
||||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
|
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
|
||||||
@ -175,7 +186,6 @@ import org.thoughtcrime.securesms.showSessionDialog
|
|||||||
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
|
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
|
||||||
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
|
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
|
||||||
import org.thoughtcrime.securesms.util.ActivityDispatcher
|
import org.thoughtcrime.securesms.util.ActivityDispatcher
|
||||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
|
||||||
import org.thoughtcrime.securesms.util.DateUtils
|
import org.thoughtcrime.securesms.util.DateUtils
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil
|
import org.thoughtcrime.securesms.util.MediaUtil
|
||||||
import org.thoughtcrime.securesms.util.NetworkUtils
|
import org.thoughtcrime.securesms.util.NetworkUtils
|
||||||
@ -211,7 +221,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
|
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
|
||||||
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate,
|
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate,
|
||||||
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
|
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
|
||||||
ConversationMenuHelper.ConversationMenuListener {
|
ConversationMenuHelper.ConversationMenuListener, View.OnClickListener {
|
||||||
|
|
||||||
private lateinit var binding: ActivityConversationV2Binding
|
private lateinit var binding: ActivityConversationV2Binding
|
||||||
|
|
||||||
@ -224,7 +234,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
@Inject lateinit var smsDb: SmsDatabase
|
@Inject lateinit var smsDb: SmsDatabase
|
||||||
@Inject lateinit var mmsDb: MmsDatabase
|
@Inject lateinit var mmsDb: MmsDatabase
|
||||||
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
|
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
|
||||||
@Inject lateinit var storage: Storage
|
@Inject lateinit var storage: StorageProtocol
|
||||||
@Inject lateinit var reactionDb: ReactionDatabase
|
@Inject lateinit var reactionDb: ReactionDatabase
|
||||||
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
||||||
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
|
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
|
||||||
@ -236,6 +246,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val conversationSettingsCallback = registerForActivityResult(ConversationSettingsActivityContract()) { result ->
|
||||||
|
if (result is ConversationSettingsActivityResult.SearchConversation) {
|
||||||
|
// open search
|
||||||
|
binding?.toolbar?.menu?.findItem(R.id.menu_search)?.expandActionView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
||||||
private val linkPreviewViewModel: LinkPreviewViewModel by lazy {
|
private val linkPreviewViewModel: LinkPreviewViewModel by lazy {
|
||||||
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
||||||
@ -269,7 +286,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val viewModel: ConversationViewModel by viewModels {
|
private val viewModel: ConversationViewModel by viewModels {
|
||||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair())
|
||||||
}
|
}
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
private var unreadCount = Int.MAX_VALUE
|
private var unreadCount = Int.MAX_VALUE
|
||||||
@ -404,6 +421,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
const val PICK_GIF = 10
|
const val PICK_GIF = 10
|
||||||
const val PICK_FROM_LIBRARY = 12
|
const val PICK_FROM_LIBRARY = 12
|
||||||
const val INVITE_CONTACTS = 124
|
const val INVITE_CONTACTS = 124
|
||||||
|
const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
@ -478,11 +496,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
updatePlaceholder()
|
updatePlaceholder()
|
||||||
setUpBlockedBanner()
|
setUpBlockedBanner()
|
||||||
binding.searchBottomBar.setEventListener(this)
|
binding.searchBottomBar.setEventListener(this)
|
||||||
|
binding.toolbarContent.profilePictureView.setOnClickListener(this)
|
||||||
updateSendAfterApprovalText()
|
updateSendAfterApprovalText()
|
||||||
setUpMessageRequestsBar()
|
setUpMessageRequests()
|
||||||
|
|
||||||
// Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
|
|
||||||
// keyboard visible and have no need to immediately display it.
|
|
||||||
|
|
||||||
val weakActivity = WeakReference(this)
|
val weakActivity = WeakReference(this)
|
||||||
|
|
||||||
@ -506,6 +522,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
setUpSearchResultObserver()
|
setUpSearchResultObserver()
|
||||||
scrollToFirstUnreadMessageIfNeeded()
|
scrollToFirstUnreadMessageIfNeeded()
|
||||||
setUpOutdatedClientBanner()
|
setUpOutdatedClientBanner()
|
||||||
|
setUpLegacyGroupBanner()
|
||||||
|
|
||||||
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
|
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
|
||||||
binding.conversationRecyclerView.scrollToPosition(targetPosition)
|
binding.conversationRecyclerView.scrollToPosition(targetPosition)
|
||||||
@ -574,6 +591,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun finish() {
|
||||||
|
super.finish()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1)
|
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1)
|
||||||
@ -809,13 +830,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
|
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
|
||||||
legacyRecipient != null
|
legacyRecipient != null
|
||||||
|
|
||||||
binding.outdatedBanner.isVisible = shouldShowLegacy
|
binding.outdatedDisappearingBanner.isVisible = shouldShowLegacy
|
||||||
if (shouldShowLegacy) {
|
if (shouldShowLegacy) {
|
||||||
|
|
||||||
val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy)
|
val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy)
|
||||||
.put(NAME_KEY, legacyRecipient!!.name)
|
.put(NAME_KEY, legacyRecipient!!.name)
|
||||||
.format()
|
.format()
|
||||||
binding?.outdatedBannerTextView?.text = txt
|
binding.outdatedBannerTextView.text = txt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setUpLegacyGroupBanner() {
|
||||||
|
val shouldDisplayBanner = viewModel.recipient?.isLegacyClosedGroupRecipient ?: return
|
||||||
|
|
||||||
|
with(binding) {
|
||||||
|
outdatedGroupBanner.isVisible = shouldDisplayBanner
|
||||||
|
outdatedGroupBanner.setOnClickListener {
|
||||||
|
showSessionDialog {
|
||||||
|
title(R.string.urlOpenBrowser)
|
||||||
|
text(R.string.urlOpenDescription)
|
||||||
|
cancelButton()
|
||||||
|
dangerButton(R.string.open) {
|
||||||
|
// open the URL (tbc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -840,21 +879,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setUpUiStateObserver() {
|
private fun setUpUiStateObserver() {
|
||||||
lifecycleScope.launchWhenStarted {
|
// Observe toast messages
|
||||||
viewModel.uiState.collect { uiState ->
|
lifecycleScope.launch {
|
||||||
uiState.uiMessages.firstOrNull()?.let {
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show()
|
viewModel.uiState
|
||||||
viewModel.messageShown(it.id)
|
.mapNotNull { it.uiMessages.firstOrNull() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { msg ->
|
||||||
|
Toast.makeText(this@ConversationActivityV2, msg.message, Toast.LENGTH_LONG).show()
|
||||||
|
viewModel.messageShown(msg.id)
|
||||||
}
|
}
|
||||||
if (uiState.isMessageRequestAccepted == true) {
|
|
||||||
binding.messageRequestBar.visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
if (!uiState.conversationExists && !isFinishing) {
|
}
|
||||||
// Conversation should be deleted now, just go back
|
|
||||||
|
// When we see "shouldExit", we finish the activity once for all.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
// Wait for `shouldExit == true` then finish the activity
|
||||||
|
viewModel.uiState
|
||||||
|
.filter { it.shouldExit }
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!isFinishing) {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observe the rest misc "simple" state change. They are bundled in one big
|
||||||
|
// state observing as these changes are relatively cheap to perform even redundantly.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
viewModel.uiState.collect { state ->
|
||||||
|
binding?.inputBar?.run {
|
||||||
|
isVisible = state.showInput
|
||||||
|
showMediaControls = state.enableInputMediaControls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int {
|
private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int {
|
||||||
@ -914,11 +977,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
if (threadRecipient.isContactRecipient) {
|
if (threadRecipient.isContactRecipient) {
|
||||||
binding.blockedBanner.isVisible = threadRecipient.isBlocked
|
binding.blockedBanner.isVisible = threadRecipient.isBlocked
|
||||||
}
|
}
|
||||||
setUpMessageRequestsBar()
|
|
||||||
invalidateOptionsMenu()
|
invalidateOptionsMenu()
|
||||||
updateSendAfterApprovalText()
|
updateSendAfterApprovalText()
|
||||||
showOrHideInputIfNeeded()
|
|
||||||
|
|
||||||
maybeUpdateToolbar(threadRecipient)
|
maybeUpdateToolbar(threadRecipient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -931,48 +991,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText
|
binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showOrHideInputIfNeeded() {
|
private fun setUpMessageRequests() {
|
||||||
binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
|
binding.acceptMessageRequestButton.setOnClickListener {
|
||||||
?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
|
viewModel.acceptMessageRequest()
|
||||||
?: true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setUpMessageRequestsBar() {
|
|
||||||
binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread()
|
|
||||||
binding.messageRequestBar.isVisible = isIncomingMessageRequestThread()
|
|
||||||
binding.acceptMessageRequestButton.setOnClickListener {
|
|
||||||
acceptMessageRequest()
|
|
||||||
}
|
|
||||||
binding.messageRequestBlock.setOnClickListener {
|
binding.messageRequestBlock.setOnClickListener {
|
||||||
block(deleteThread = true)
|
block(deleteThread = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.declineMessageRequestButton.setOnClickListener {
|
binding.declineMessageRequestButton.setOnClickListener {
|
||||||
viewModel.declineMessageRequest()
|
viewModel.declineMessageRequest()
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
|
|
||||||
}
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun acceptMessageRequest() {
|
lifecycleScope.launch {
|
||||||
binding.messageRequestBar.isVisible = false
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
viewModel.acceptMessageRequest()
|
viewModel.uiState
|
||||||
|
.map { it.messageRequestState }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { state ->
|
||||||
|
binding.messageRequestBar.isVisible = state != MessageRequestUiState.Invisible
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
if (state is MessageRequestUiState.Visible) {
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
|
binding.sendAcceptsTextView.setText(state.acceptButtonText)
|
||||||
|
binding.messageRequestBlock.isVisible = state.showBlockButton
|
||||||
|
binding.declineMessageRequestButton.setText(state.declineButtonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run {
|
|
||||||
!isGroupRecipient && !isLocalNumber &&
|
|
||||||
!(hasApprovedMe() || viewModel.hasReceived())
|
|
||||||
} ?: false
|
|
||||||
|
|
||||||
private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run {
|
|
||||||
!isGroupRecipient && !isApproved && !isLocalNumber &&
|
|
||||||
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0
|
|
||||||
} ?: false
|
|
||||||
|
|
||||||
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
|
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
|
||||||
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
|
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
|
||||||
@ -1174,20 +1222,35 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
} ?: false
|
} ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View?) {
|
||||||
|
if (v === binding?.toolbarContent?.profilePictureView) {
|
||||||
|
// open conversation settings
|
||||||
|
conversationSettingsCallback.launch(viewModel.threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun block(deleteThread: Boolean) {
|
override fun block(deleteThread: Boolean) {
|
||||||
val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action")
|
val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action")
|
||||||
|
val invitingAdmin = viewModel.invitingAdmin
|
||||||
|
|
||||||
|
val name = if (recipient.isClosedGroupV2Recipient && invitingAdmin != null) {
|
||||||
|
invitingAdmin.getSearchName()
|
||||||
|
} else {
|
||||||
|
recipient.name
|
||||||
|
}
|
||||||
|
|
||||||
showSessionDialog {
|
showSessionDialog {
|
||||||
title(R.string.block)
|
title(R.string.block)
|
||||||
text(
|
text(
|
||||||
Phrase.from(context, R.string.blockDescription)
|
Phrase.from(context, R.string.blockDescription)
|
||||||
.put(NAME_KEY, recipient.name)
|
.put(NAME_KEY, name)
|
||||||
.format()
|
.format()
|
||||||
)
|
)
|
||||||
dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) {
|
dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) {
|
||||||
viewModel.block()
|
viewModel.block()
|
||||||
|
|
||||||
// Block confirmation toast added as per SS-64
|
// Block confirmation toast added as per SS-64
|
||||||
val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, recipient.name).format().toString()
|
val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, name).format().toString()
|
||||||
Toast.makeText(context, txt, Toast.LENGTH_LONG).show()
|
Toast.makeText(context, txt, Toast.LENGTH_LONG).show()
|
||||||
|
|
||||||
if (deleteThread) {
|
if (deleteThread) {
|
||||||
@ -1218,8 +1281,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: don't need to allow new closed group check here, removed in new disappearing messages
|
||||||
override fun showDisappearingMessages(thread: Recipient) {
|
override fun showDisappearingMessages(thread: Recipient) {
|
||||||
if (thread.isClosedGroupRecipient) {
|
if (thread.isLegacyClosedGroupRecipient) {
|
||||||
groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
|
groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
|
||||||
}
|
}
|
||||||
Intent(this, DisappearingMessagesActivity::class.java)
|
Intent(this, DisappearingMessagesActivity::class.java)
|
||||||
@ -1687,19 +1751,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY)
|
startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processMessageRequestApproval() {
|
|
||||||
if (isIncomingMessageRequestThread()) {
|
|
||||||
acceptMessageRequest()
|
|
||||||
} else if (viewModel.recipient?.isApproved == false) {
|
|
||||||
// edge case for new outgoing thread on new recipient without sending approval messages
|
|
||||||
viewModel.setRecipientApproved()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? {
|
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? {
|
||||||
val recipient = viewModel.recipient ?: return null
|
val recipient = viewModel.recipient ?: return null
|
||||||
val sentTimestamp = SnodeAPI.nowWithOffset
|
val sentTimestamp = SnodeAPI.nowWithOffset
|
||||||
processMessageRequestApproval()
|
viewModel.beforeSendingTextOnlyMessage()
|
||||||
val text = getMessageBody()
|
val text = getMessageBody()
|
||||||
val userPublicKey = textSecurePreferences.getLocalNumber()
|
val userPublicKey = textSecurePreferences.getLocalNumber()
|
||||||
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
|
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
|
||||||
@ -1743,7 +1798,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
): Pair<Address, Long>? {
|
): Pair<Address, Long>? {
|
||||||
val recipient = viewModel.recipient ?: return null
|
val recipient = viewModel.recipient ?: return null
|
||||||
val sentTimestamp = SnodeAPI.nowWithOffset
|
val sentTimestamp = SnodeAPI.nowWithOffset
|
||||||
processMessageRequestApproval()
|
viewModel.beforeSendingAttachments()
|
||||||
// Create the message
|
// Create the message
|
||||||
val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
|
val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
|
||||||
message.sentTimestamp = sentTimestamp
|
message.sentTimestamp = sentTimestamp
|
||||||
@ -2088,7 +2143,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
cancelButton { endActionMode() }
|
cancelButton { endActionMode() }
|
||||||
}
|
}
|
||||||
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
|
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
|
||||||
} else if (allSentByCurrentUser && allHasHash) {
|
} else if ((allSentByCurrentUser || viewModel.isClosedGroupAdmin) && allHasHash) {
|
||||||
val bottomSheet = DeleteOptionsBottomSheet()
|
val bottomSheet = DeleteOptionsBottomSheet()
|
||||||
bottomSheet.recipient = recipient
|
bottomSheet.recipient = recipient
|
||||||
bottomSheet.onDeleteForMeTapped = {
|
bottomSheet.onDeleteForMeTapped = {
|
||||||
|
@ -1,51 +1,60 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.goterl.lazysodium.utils.KeyPair
|
import com.goterl.lazysodium.utils.KeyPair
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import network.loki.messenger.libsession_util.util.GroupMember
|
||||||
import org.session.libsession.database.MessageDataProvider
|
import org.session.libsession.database.MessageDataProvider
|
||||||
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.session.libsession.messaging.utilities.AccountId
|
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
|
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
|
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class ConversationViewModel(
|
class ConversationViewModel(
|
||||||
val threadId: Long,
|
val threadId: Long,
|
||||||
val edKeyPair: KeyPair?,
|
val edKeyPair: KeyPair?,
|
||||||
private val repository: ConversationRepository,
|
private val repository: ConversationRepository,
|
||||||
private val storage: Storage,
|
private val storage: StorageProtocol,
|
||||||
private val messageDataProvider: MessageDataProvider,
|
private val messageDataProvider: MessageDataProvider,
|
||||||
database: MmsDatabase,
|
private val groupDb: GroupDatabase,
|
||||||
|
private val threadDb: ThreadDatabase,
|
||||||
|
private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val showSendAfterApprovalText: Boolean
|
val showSendAfterApprovalText: Boolean
|
||||||
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
|
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
|
private val _uiState = MutableStateFlow(ConversationUiState())
|
||||||
val uiState: StateFlow<ConversationUiState> = _uiState
|
val uiState: StateFlow<ConversationUiState> get() = _uiState
|
||||||
|
|
||||||
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
|
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
|
||||||
repository.maybeGetRecipientForThreadId(threadId)
|
repository.maybeGetRecipientForThreadId(threadId)
|
||||||
@ -65,12 +74,39 @@ class ConversationViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin who invites us to this group(v2) conversation.
|
||||||
|
*
|
||||||
|
* null if this convo is not a group(v2) conversation, or error getting the info
|
||||||
|
*/
|
||||||
|
val invitingAdmin: Recipient?
|
||||||
|
get() {
|
||||||
|
val recipient = recipient ?: return null
|
||||||
|
if (!recipient.isClosedGroupV2Recipient) return null
|
||||||
|
|
||||||
|
return repository.getInvitingAdmin(threadId)
|
||||||
|
}
|
||||||
|
|
||||||
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
|
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
|
||||||
storage.getOpenGroup(threadId)
|
storage.getOpenGroup(threadId)
|
||||||
}
|
}
|
||||||
val openGroup: OpenGroup?
|
val openGroup: OpenGroup?
|
||||||
get() = _openGroup.value
|
get() = _openGroup.value
|
||||||
|
|
||||||
|
private val closedGroupMembers: List<GroupMember>
|
||||||
|
get() {
|
||||||
|
val recipient = recipient ?: return emptyList()
|
||||||
|
if (!recipient.isClosedGroupV2Recipient) return emptyList()
|
||||||
|
return storage.getMembers(recipient.address.serialize())
|
||||||
|
}
|
||||||
|
|
||||||
|
val isClosedGroupAdmin: Boolean
|
||||||
|
get() {
|
||||||
|
val recipient = recipient ?: return false
|
||||||
|
return !recipient.isClosedGroupV2Recipient ||
|
||||||
|
(closedGroupMembers.firstOrNull { it.sessionId == storage.getUserPublicKey() }?.admin ?: false)
|
||||||
|
}
|
||||||
|
|
||||||
val serverCapabilities: List<String>
|
val serverCapabilities: List<String>
|
||||||
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
|
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
|
||||||
|
|
||||||
@ -83,7 +119,7 @@ class ConversationViewModel(
|
|||||||
val isMessageRequestThread : Boolean
|
val isMessageRequestThread : Boolean
|
||||||
get() {
|
get() {
|
||||||
val recipient = recipient ?: return false
|
val recipient = recipient ?: return false
|
||||||
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
|
return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved
|
||||||
}
|
}
|
||||||
|
|
||||||
val canReactToMessages: Boolean
|
val canReactToMessages: Boolean
|
||||||
@ -97,16 +133,99 @@ class ConversationViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
repository.recipientUpdateFlow(threadId)
|
repository.recipientUpdateFlow(threadId)
|
||||||
.collect { recipient ->
|
.collect { recipient ->
|
||||||
if (recipient == null && _uiState.value.conversationExists) {
|
_uiState.update {
|
||||||
_uiState.update { it.copy(conversationExists = false) }
|
it.copy(
|
||||||
|
shouldExit = recipient == null,
|
||||||
|
showInput = shouldShowInput(recipient),
|
||||||
|
enableInputMediaControls = shouldEnableInputMediaControls(recipient),
|
||||||
|
messageRequestState = buildMessageRequestState(recipient),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the input media controls should be enabled.
|
||||||
|
*
|
||||||
|
* Normally we will show the input media controls, only in these situations we hide them:
|
||||||
|
* 1. First time we send message to a person.
|
||||||
|
* Since we haven't been approved by them, we can't send them any media, only text
|
||||||
|
*/
|
||||||
|
private fun shouldEnableInputMediaControls(recipient: Recipient?): Boolean {
|
||||||
|
if (recipient != null &&
|
||||||
|
(recipient.is1on1 && !recipient.isLocalNumber) &&
|
||||||
|
!recipient.hasApprovedMe()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the input bar should be shown.
|
||||||
|
*
|
||||||
|
* For these situations we hide the input bar:
|
||||||
|
* 1. The user has been kicked from a group(v2), OR
|
||||||
|
* 2. The legacy group is inactive, OR
|
||||||
|
* 3. The community chat is read only
|
||||||
|
*/
|
||||||
|
private fun shouldShowInput(recipient: Recipient?): Boolean {
|
||||||
|
return when {
|
||||||
|
recipient?.isClosedGroupV2Recipient == true -> !repository.isKicked(recipient)
|
||||||
|
recipient?.isLegacyClosedGroupRecipient == true -> {
|
||||||
|
groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true
|
||||||
|
}
|
||||||
|
openGroup != null -> openGroup?.canWrite == true
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMessageRequestState(recipient: Recipient?): MessageRequestUiState {
|
||||||
|
// The basic requirement of showing a message request is:
|
||||||
|
// 1. The other party has not been approved by us, AND
|
||||||
|
// 2. We haven't sent a message to them before (if we do, we would be the one requesting permission), AND
|
||||||
|
// 3. We have received message from them AND
|
||||||
|
// 4. The type of conversation supports message request (only 1to1 and groups v2)
|
||||||
|
|
||||||
|
if (
|
||||||
|
recipient != null &&
|
||||||
|
|
||||||
|
// Req 1: we haven't approved the other party
|
||||||
|
(!recipient.isApproved && !recipient.isLocalNumber) &&
|
||||||
|
|
||||||
|
// Req 4: the type of conversation supports message request
|
||||||
|
(recipient.is1on1 || recipient.isClosedGroupV2Recipient) &&
|
||||||
|
|
||||||
|
// Req 2: we haven't sent a message to them before
|
||||||
|
!threadDb.getLastSeenAndHasSent(threadId).second() &&
|
||||||
|
|
||||||
|
// Req 3: we have received message from them
|
||||||
|
threadDb.getMessageCount(threadId) > 0
|
||||||
|
) {
|
||||||
|
|
||||||
|
return MessageRequestUiState.Visible(
|
||||||
|
acceptButtonText = if (recipient.isGroupRecipient) {
|
||||||
|
R.string.messageRequestGroupInviteDescription
|
||||||
|
} else {
|
||||||
|
R.string.messageRequestsAcceptDescription
|
||||||
|
},
|
||||||
|
// You can block a 1to1 conversation, or a normal groups v2 conversation
|
||||||
|
showBlockButton = recipient.is1on1 || recipient.isClosedGroupV2Recipient,
|
||||||
|
declineButtonText = if (recipient.isClosedGroupV2Recipient) {
|
||||||
|
R.string.delete
|
||||||
|
} else {
|
||||||
|
R.string.decline
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageRequestUiState.Invisible
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
|
||||||
@ -135,16 +254,17 @@ class ConversationViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun block() {
|
fun block() {
|
||||||
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
|
// inviting admin will be true if this request is a closed group message request
|
||||||
if (recipient.isContactRecipient) {
|
val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action")
|
||||||
repository.setBlocked(recipient, true)
|
if (recipient.isContactRecipient || recipient.isClosedGroupV2Recipient) {
|
||||||
|
repository.setBlocked(threadId, recipient, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unblock() {
|
fun unblock() {
|
||||||
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
|
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
|
||||||
if (recipient.isContactRecipient) {
|
if (recipient.isContactRecipient) {
|
||||||
repository.setBlocked(recipient, false)
|
repository.setBlocked(threadId, recipient, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,11 +287,6 @@ class ConversationViewModel(
|
|||||||
AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop()
|
AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setRecipientApproved() {
|
|
||||||
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action")
|
|
||||||
repository.setApproved(recipient, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
|
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
|
||||||
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
|
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
|
||||||
stopPlayingAudioMessage(message)
|
stopPlayingAudioMessage(message)
|
||||||
@ -221,19 +336,36 @@ class ConversationViewModel(
|
|||||||
|
|
||||||
fun acceptMessageRequest() = viewModelScope.launch {
|
fun acceptMessageRequest() = viewModelScope.launch {
|
||||||
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action")
|
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action")
|
||||||
|
val currentState = _uiState.value.messageRequestState as? MessageRequestUiState.Visible
|
||||||
|
?: return@launch Log.w("Loki", "Current state was not visible for accept message request action")
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(messageRequestState = MessageRequestUiState.Pending(currentState))
|
||||||
|
}
|
||||||
|
|
||||||
repository.acceptMessageRequest(threadId, recipient)
|
repository.acceptMessageRequest(threadId, recipient)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isMessageRequestAccepted = true)
|
it.copy(messageRequestState = MessageRequestUiState.Invisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
showMessage("Couldn't accept message request due to error: $it")
|
showMessage("Couldn't accept message request due to error: $it")
|
||||||
|
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(messageRequestState = currentState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun declineMessageRequest() {
|
fun declineMessageRequest() {
|
||||||
repository.declineMessageRequest(threadId)
|
repository.declineMessageRequest(threadId, recipient!!)
|
||||||
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
|
||||||
|
_uiState.update { it.copy(shouldExit = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showMessage(message: String) {
|
private fun showMessage(message: String) {
|
||||||
@ -278,6 +410,25 @@ class ConversationViewModel(
|
|||||||
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
|
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun beforeSendingTextOnlyMessage() {
|
||||||
|
implicitlyApproveRecipient()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beforeSendingAttachments() {
|
||||||
|
implicitlyApproveRecipient()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun implicitlyApproveRecipient() {
|
||||||
|
val recipient = recipient
|
||||||
|
|
||||||
|
if (uiState.value.messageRequestState is MessageRequestUiState.Visible) {
|
||||||
|
acceptMessageRequest()
|
||||||
|
} else if (recipient?.isApproved == false) {
|
||||||
|
// edge case for new outgoing thread on new recipient without sending approval messages
|
||||||
|
repository.setApproved(recipient, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@dagger.assisted.AssistedFactory
|
@dagger.assisted.AssistedFactory
|
||||||
interface AssistedFactory {
|
interface AssistedFactory {
|
||||||
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
||||||
@ -288,9 +439,12 @@ class ConversationViewModel(
|
|||||||
@Assisted private val threadId: Long,
|
@Assisted private val threadId: Long,
|
||||||
@Assisted private val edKeyPair: KeyPair?,
|
@Assisted private val edKeyPair: KeyPair?,
|
||||||
private val repository: ConversationRepository,
|
private val repository: ConversationRepository,
|
||||||
private val storage: Storage,
|
private val storage: StorageProtocol,
|
||||||
private val mmsDatabase: MmsDatabase,
|
|
||||||
private val messageDataProvider: MessageDataProvider,
|
private val messageDataProvider: MessageDataProvider,
|
||||||
|
private val groupDb: GroupDatabase,
|
||||||
|
private val threadDb: ThreadDatabase,
|
||||||
|
@ApplicationContext
|
||||||
|
private val context: Context,
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
@ -300,7 +454,9 @@ class ConversationViewModel(
|
|||||||
repository = repository,
|
repository = repository,
|
||||||
storage = storage,
|
storage = storage,
|
||||||
messageDataProvider = messageDataProvider,
|
messageDataProvider = messageDataProvider,
|
||||||
database = mmsDatabase
|
groupDb = groupDb,
|
||||||
|
threadDb = threadDb,
|
||||||
|
appContext = context,
|
||||||
) as T
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -310,10 +466,24 @@ data class UiMessage(val id: Long, val message: String)
|
|||||||
|
|
||||||
data class ConversationUiState(
|
data class ConversationUiState(
|
||||||
val uiMessages: List<UiMessage> = emptyList(),
|
val uiMessages: List<UiMessage> = emptyList(),
|
||||||
val isMessageRequestAccepted: Boolean? = null,
|
val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible,
|
||||||
val conversationExists: Boolean
|
val shouldExit: Boolean = false,
|
||||||
|
val showInput: Boolean = true,
|
||||||
|
val enableInputMediaControls: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sealed interface MessageRequestUiState {
|
||||||
|
data object Invisible : MessageRequestUiState
|
||||||
|
|
||||||
|
data class Pending(val prevState: Visible) : MessageRequestUiState
|
||||||
|
|
||||||
|
data class Visible(
|
||||||
|
@StringRes val acceptButtonText: Int,
|
||||||
|
val showBlockButton: Boolean,
|
||||||
|
@StringRes val declineButtonText: Int,
|
||||||
|
) : MessageRequestUiState
|
||||||
|
}
|
||||||
|
|
||||||
data class RetrieveOnce<T>(val retrieval: () -> T?) {
|
data class RetrieveOnce<T>(val retrieval: () -> T?) {
|
||||||
private var triedToRetrieve: Boolean = false
|
private var triedToRetrieve: Boolean = false
|
||||||
private var _value: T? = null
|
private var _value: T? = null
|
||||||
|
@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
|
|||||||
if (!this::recipient.isInitialized) {
|
if (!this::recipient.isInitialized) {
|
||||||
return dismiss()
|
return dismiss()
|
||||||
}
|
}
|
||||||
if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
|
if (recipient.isLocalNumber) {
|
||||||
binding.deleteForEveryoneTextView.text =
|
binding.deleteForEveryoneTextView.text =
|
||||||
resources.getString(R.string.clearMessagesForEveryone, contact)
|
getString(R.string.clearMessagesForMe)
|
||||||
|
} else if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
|
||||||
|
binding.deleteForEveryoneTextView.text =
|
||||||
|
resources.getString(R.string.clearMessagesForEveryone)
|
||||||
}
|
}
|
||||||
binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient
|
binding.deleteForEveryoneTextView.isVisible = !recipient.isLegacyClosedGroupRecipient
|
||||||
binding.deleteForMeTextView.setOnClickListener(this)
|
binding.deleteForMeTextView.setOnClickListener(this)
|
||||||
binding.deleteForEveryoneTextView.setOnClickListener(this)
|
binding.deleteForEveryoneTextView.setOnClickListener(this)
|
||||||
binding.cancelTextView.setOnClickListener(this)
|
binding.cancelTextView.setOnClickListener(this)
|
||||||
|
@ -54,6 +54,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||||
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
||||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||||
@ -84,7 +85,7 @@ import javax.inject.Inject
|
|||||||
class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var storage: Storage
|
lateinit var storage: StorageProtocol
|
||||||
|
|
||||||
private val viewModel: MessageDetailsViewModel by viewModels()
|
private val viewModel: MessageDetailsViewModel by viewModels()
|
||||||
|
|
||||||
|
@ -10,49 +10,58 @@ import androidx.fragment.app.DialogFragment
|
|||||||
import com.squareup.phrase.Phrase
|
import com.squareup.phrase.Phrase
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
|
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
|
||||||
import org.thoughtcrime.securesms.createSessionDialog
|
import org.thoughtcrime.securesms.createSessionDialog
|
||||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** Shown when receiving media from a contact for the first time, to confirm that
|
/** Shown when receiving media from a contact for the first time, to confirm that
|
||||||
* they are to be trusted and files sent by them are to be downloaded. */
|
* they are to be trusted and files sent by them are to be downloaded. */
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
|
class AutoDownloadDialog(private val threadRecipient: Recipient,
|
||||||
|
private val databaseAttachment: DatabaseAttachment
|
||||||
|
) : DialogFragment() {
|
||||||
|
|
||||||
|
@Inject lateinit var storage: StorageProtocol
|
||||||
@Inject lateinit var contactDB: SessionContactDatabase
|
@Inject lateinit var contactDB: SessionContactDatabase
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
||||||
val accountID = recipient.address.toString()
|
val threadId = storage.getThreadId(threadRecipient) ?: run {
|
||||||
val contact = contactDB.getContactWithAccountID(accountID)
|
dismiss()
|
||||||
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
|
return@createSessionDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayName = when {
|
||||||
|
threadRecipient.isCommunityRecipient -> storage.getOpenGroup(threadId)?.name ?: "UNKNOWN"
|
||||||
|
threadRecipient.isLegacyClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN"
|
||||||
|
threadRecipient.isClosedGroupV2Recipient -> threadRecipient.name ?: "UNKNOWN"
|
||||||
|
else -> storage.getContactWithAccountID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN"
|
||||||
|
}
|
||||||
title(getString(R.string.attachmentsAutoDownloadModalTitle))
|
title(getString(R.string.attachmentsAutoDownloadModalTitle))
|
||||||
|
|
||||||
val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription)
|
val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription)
|
||||||
.put(CONVERSATION_NAME_KEY, recipient.name)
|
.put(CONVERSATION_NAME_KEY, displayName)
|
||||||
.format()
|
.format()
|
||||||
val spannable = SpannableStringBuilder(explanation)
|
val spannable = SpannableStringBuilder(explanation)
|
||||||
|
val startIndex = explanation.indexOf(displayName)
|
||||||
val startIndex = explanation.indexOf(name)
|
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
||||||
text(spannable)
|
text(spannable)
|
||||||
|
|
||||||
button(R.string.download, R.string.AccessibilityId_download) { trust() }
|
button(R.string.download, R.string.AccessibilityId_download) {
|
||||||
|
setAutoDownload()
|
||||||
|
}
|
||||||
|
|
||||||
cancelButton { dismiss() }
|
cancelButton { dismiss() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun trust() {
|
private fun setAutoDownload() {
|
||||||
val accountID = recipient.address.toString()
|
storage.setAutoDownloadAttachments(threadRecipient, true)
|
||||||
val contact = contactDB.getContactWithAccountID(accountID) ?: return
|
JobQueue.shared.createAndStartAttachmentDownload(databaseAttachment)
|
||||||
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
|
|
||||||
contactDB.setContactIsTrusted(contact, true, threadID)
|
|
||||||
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
|
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,8 +60,13 @@ class InputBar @JvmOverloads constructor(
|
|||||||
var delegate: InputBarDelegate? = null
|
var delegate: InputBarDelegate? = null
|
||||||
var quote: MessageRecord? = null
|
var quote: MessageRecord? = null
|
||||||
var linkPreview: LinkPreview? = null
|
var linkPreview: LinkPreview? = null
|
||||||
var showInput: Boolean = true
|
private var showInput: Boolean = true
|
||||||
set(value) { field = value; showOrHideInputIfNeeded() }
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
showOrHideInputIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
var showMediaControls: Boolean = true
|
var showMediaControls: Boolean = true
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
@ -252,20 +257,20 @@ class InputBar @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showOrHideInputIfNeeded() {
|
private fun showOrHideInputIfNeeded() {
|
||||||
if (showInput) {
|
if (!showInput) {
|
||||||
setOf( binding.inputBarEditText, attachmentsButton ).forEach { it.isVisible = true }
|
|
||||||
microphoneButton.isVisible = text.isEmpty()
|
|
||||||
sendButton.isVisible = text.isNotEmpty()
|
|
||||||
} else {
|
|
||||||
cancelQuoteDraft()
|
cancelQuoteDraft()
|
||||||
cancelLinkPreviewDraft()
|
cancelLinkPreviewDraft()
|
||||||
val views = setOf( binding.inputBarEditText, attachmentsButton, microphoneButton, sendButton )
|
|
||||||
views.forEach { it.isVisible = false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.inputBarEditText.isVisible = showInput
|
||||||
|
attachmentsButton.isVisible = showInput
|
||||||
|
microphoneButton.isVisible = showInput && text.isEmpty()
|
||||||
|
sendButton.isVisible = showInput && text.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showOrHideMediaControlsIfNeeded() {
|
private fun showOrHideMediaControlsIfNeeded() {
|
||||||
setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls }
|
attachmentsButton.snIsEnabled = showMediaControls
|
||||||
|
microphoneButton.snIsEnabled = showMediaControls
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTextChangedListener(listener: (String) -> Unit) {
|
fun addTextChangedListener(listener: (String) -> Unit) {
|
||||||
|
@ -85,10 +85,13 @@ class MentionViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val memberIDs = when {
|
val memberIDs = when {
|
||||||
recipient.isClosedGroupRecipient -> {
|
recipient.isLegacyClosedGroupRecipient -> {
|
||||||
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
|
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
|
||||||
.map { it.serialize() }
|
.map { it.serialize() }
|
||||||
}
|
}
|
||||||
|
recipient.isClosedGroupV2Recipient -> {
|
||||||
|
storage.getMembers(recipient.address.serialize()).map { it.sessionId }
|
||||||
|
}
|
||||||
|
|
||||||
recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20)
|
recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20)
|
||||||
recipient.isContactRecipient -> listOf(recipient.address.serialize())
|
recipient.isContactRecipient -> listOf(recipient.address.serialize())
|
||||||
|
@ -6,10 +6,10 @@ import android.view.Menu
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.utilities.AccountId
|
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
|
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
|
||||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
@ -37,7 +37,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
|||||||
val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
|
val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
|
||||||
val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!!
|
val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!!
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
||||||
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
|
val edKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!
|
||||||
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
|
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
|
||||||
?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
|
?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
|
||||||
|
|
||||||
|
@ -34,8 +34,8 @@ import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
|||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
|
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
|
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
|
||||||
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
|
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
|
||||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||||
import org.thoughtcrime.securesms.service.WebRtcCallService
|
import org.thoughtcrime.securesms.service.WebRtcCallService
|
||||||
import org.thoughtcrime.securesms.showMuteDialog
|
import org.thoughtcrime.securesms.showMuteDialog
|
||||||
@ -56,7 +56,7 @@ object ConversationMenuHelper {
|
|||||||
// Base menu (options that should always be present)
|
// Base menu (options that should always be present)
|
||||||
inflater.inflate(R.menu.menu_conversation, menu)
|
inflater.inflate(R.menu.menu_conversation, menu)
|
||||||
// Expiring messages
|
// Expiring messages
|
||||||
if (!isCommunity && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
|
if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyClosedGroupRecipient || thread.isLocalNumber)) {
|
||||||
inflater.inflate(R.menu.menu_conversation_expiration, menu)
|
inflater.inflate(R.menu.menu_conversation_expiration, menu)
|
||||||
}
|
}
|
||||||
// One-on-one chat menu allows copying the account id
|
// One-on-one chat menu allows copying the account id
|
||||||
@ -72,7 +72,7 @@ object ConversationMenuHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Closed group menu (options that should only be present in closed groups)
|
// Closed group menu (options that should only be present in closed groups)
|
||||||
if (thread.isClosedGroupRecipient) {
|
if (thread.isLegacyClosedGroupRecipient) {
|
||||||
inflater.inflate(R.menu.menu_conversation_closed_group, menu)
|
inflater.inflate(R.menu.menu_conversation_closed_group, menu)
|
||||||
}
|
}
|
||||||
// Open group menu
|
// Open group menu
|
||||||
@ -258,15 +258,15 @@ object ConversationMenuHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun editClosedGroup(context: Context, thread: Recipient) {
|
private fun editClosedGroup(context: Context, thread: Recipient) {
|
||||||
if (!thread.isClosedGroupRecipient) { return }
|
if (!thread.isLegacyClosedGroupRecipient) { return }
|
||||||
val intent = Intent(context, EditClosedGroupActivity::class.java)
|
val intent = Intent(context, EditLegacyGroupActivity::class.java)
|
||||||
val groupID: String = thread.address.toGroupString()
|
val groupID: String = thread.address.toGroupString()
|
||||||
intent.putExtra(groupIDKey, groupID)
|
intent.putExtra(groupIDKey, groupID)
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun leaveClosedGroup(context: Context, thread: Recipient) {
|
private fun leaveClosedGroup(context: Context, thread: Recipient) {
|
||||||
if (!thread.isClosedGroupRecipient) { return }
|
if (!thread.isLegacyClosedGroupRecipient) { return }
|
||||||
|
|
||||||
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
|
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
|
||||||
val admins = group.admins
|
val admins = group.admins
|
||||||
|
@ -16,6 +16,8 @@ import network.loki.messenger.databinding.ViewControlMessageBinding
|
|||||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
|
import org.session.libsession.messaging.utilities.UpdateMessageData
|
||||||
|
import org.session.libsession.utilities.getColorFromAttr
|
||||||
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
|
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
|
||||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
|
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
|
||||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
|
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
|
||||||
@ -45,6 +47,7 @@ class ControlMessageView : LinearLayout {
|
|||||||
binding.expirationTimerView.isGone = true
|
binding.expirationTimerView.isGone = true
|
||||||
binding.followSetting.isGone = true
|
binding.followSetting.isGone = true
|
||||||
var messageBody: CharSequence = message.getDisplayBody(context)
|
var messageBody: CharSequence = message.getDisplayBody(context)
|
||||||
|
|
||||||
binding.root.contentDescription = null
|
binding.root.contentDescription = null
|
||||||
binding.textView.text = messageBody
|
binding.textView.text = messageBody
|
||||||
when {
|
when {
|
||||||
@ -54,7 +57,7 @@ class ControlMessageView : LinearLayout {
|
|||||||
|
|
||||||
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
|
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
|
||||||
|
|
||||||
if (threadRecipient?.isClosedGroupRecipient == true) {
|
if (threadRecipient?.isClosedGroupV2Recipient == true) {
|
||||||
expirationTimerView.setTimerIcon()
|
expirationTimerView.setTimerIcon()
|
||||||
} else {
|
} else {
|
||||||
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||||
@ -98,6 +101,12 @@ class ControlMessageView : LinearLayout {
|
|||||||
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
message.isGroupUpdateMessage -> {
|
||||||
|
val updateMessageData: UpdateMessageData? = UpdateMessageData.fromJSON(message.body)
|
||||||
|
if (updateMessageData?.isGroupErrorQuitKind() == true) {
|
||||||
|
binding.textView.setTextColor(context.getColorFromAttr(R.attr.danger))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.textView.isGone = message.isCallLog
|
binding.textView.isGone = message.isCallLog
|
||||||
|
@ -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.R
|
||||||
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.session.libsession.utilities.ThemeUtil
|
import org.session.libsession.utilities.ThemeUtil
|
||||||
import org.session.libsession.utilities.getColorFromAttr
|
import org.session.libsession.utilities.getColorFromAttr
|
||||||
@ -34,7 +35,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
|
|||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
|
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
|
|
||||||
import org.thoughtcrime.securesms.util.GlowViewUtilities
|
import org.thoughtcrime.securesms.util.GlowViewUtilities
|
||||||
import org.thoughtcrime.securesms.util.SearchUtil
|
import org.thoughtcrime.securesms.util.SearchUtil
|
||||||
import org.thoughtcrime.securesms.util.getAccentColor
|
import org.thoughtcrime.securesms.util.getAccentColor
|
||||||
@ -61,7 +61,6 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
glide: RequestManager = Glide.with(this),
|
glide: RequestManager = Glide.with(this),
|
||||||
thread: Recipient,
|
thread: Recipient,
|
||||||
searchQuery: String? = null,
|
searchQuery: String? = null,
|
||||||
contactIsTrusted: Boolean = true,
|
|
||||||
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
|
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
|
||||||
suppressThumbnails: Boolean = false
|
suppressThumbnails: Boolean = false
|
||||||
) {
|
) {
|
||||||
@ -71,8 +70,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
binding.contentParent.mainColor = color
|
binding.contentParent.mainColor = color
|
||||||
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
|
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
|
||||||
|
|
||||||
val onlyBodyMessage = message is SmsMessageRecord
|
val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE }
|
||||||
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
|
val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress }
|
||||||
|
val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
|
||||||
|
|
||||||
// reset visibilities / containers
|
// reset visibilities / containers
|
||||||
onContentClick.clear()
|
onContentClick.clear()
|
||||||
@ -85,7 +85,6 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
binding.bodyTextView.isVisible = false
|
binding.bodyTextView.isVisible = false
|
||||||
binding.quoteView.root.isVisible = false
|
binding.quoteView.root.isVisible = false
|
||||||
binding.linkPreviewView.root.isVisible = false
|
binding.linkPreviewView.root.isVisible = false
|
||||||
binding.untrustedView.root.isVisible = false
|
|
||||||
binding.voiceMessageView.root.isVisible = false
|
binding.voiceMessageView.root.isVisible = false
|
||||||
binding.documentView.root.isVisible = false
|
binding.documentView.root.isVisible = false
|
||||||
binding.albumThumbnailView.root.isVisible = false
|
binding.albumThumbnailView.root.isVisible = false
|
||||||
@ -100,9 +99,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
binding.bodyTextView.text = null
|
binding.bodyTextView.text = null
|
||||||
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
|
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
|
||||||
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
|
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
|
||||||
binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
|
binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
|
||||||
binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null
|
binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null
|
||||||
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null
|
binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null
|
||||||
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
|
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
|
||||||
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
|
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
|
||||||
|
|
||||||
@ -140,6 +139,7 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
|
// LINK PREVIEW
|
||||||
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
|
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
|
||||||
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||||
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
|
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
|
||||||
@ -147,10 +147,11 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
// When in a link preview ensure the bodyTextView can expand to the full width
|
// When in a link preview ensure the bodyTextView can expand to the full width
|
||||||
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
|
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
|
||||||
}
|
}
|
||||||
|
// AUDIO
|
||||||
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
|
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
|
||||||
hideBody = true
|
hideBody = true
|
||||||
// Audio attachment
|
// Audio attachment
|
||||||
if (contactIsTrusted || message.isOutgoing) {
|
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
|
||||||
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
|
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
|
||||||
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
|
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
|
||||||
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
|
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||||
@ -159,26 +160,38 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
|
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
|
||||||
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
|
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
|
||||||
} else {
|
} else {
|
||||||
// TODO: move this out to its own area
|
|
||||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
|
|
||||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
|
|
||||||
hideBody = true
|
hideBody = true
|
||||||
|
(message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||||
|
binding.pendingAttachmentView.root.bind(
|
||||||
|
PendingAttachmentView.AttachmentType.AUDIO,
|
||||||
|
getTextColor(context,message),
|
||||||
|
attachment
|
||||||
|
)
|
||||||
|
onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DOCUMENT
|
||||||
|
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
|
||||||
|
hideBody = true // TODO: check if this is still the logic we want
|
||||||
// Document attachment
|
// Document attachment
|
||||||
if (contactIsTrusted || message.isOutgoing) {
|
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
|
||||||
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
binding.documentView.root.bind(message, getTextColor(context, message))
|
||||||
} else {
|
} else {
|
||||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
|
hideBody = true
|
||||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
(message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||||
|
binding.pendingAttachmentView.root.bind(
|
||||||
|
PendingAttachmentView.AttachmentType.DOCUMENT,
|
||||||
|
getTextColor(context,message),
|
||||||
|
attachment
|
||||||
|
)
|
||||||
|
onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// IMAGE / VIDEO
|
||||||
message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
|
message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
|
||||||
/*
|
if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
|
||||||
* Images / Video attachment
|
|
||||||
*/
|
|
||||||
if (contactIsTrusted || message.isOutgoing) {
|
|
||||||
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
|
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
|
||||||
// bind after add view because views are inflated and calculated during bind
|
// bind after add view because views are inflated and calculated during bind
|
||||||
binding.albumThumbnailView.root.bind(
|
binding.albumThumbnailView.root.bind(
|
||||||
@ -196,13 +209,22 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
} else {
|
} else {
|
||||||
hideBody = true
|
hideBody = true
|
||||||
binding.albumThumbnailView.root.clearViews()
|
binding.albumThumbnailView.root.clearViews()
|
||||||
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
|
val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment
|
||||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
firstAttachment?.let { attachment ->
|
||||||
|
binding.pendingAttachmentView.root.bind(
|
||||||
|
PendingAttachmentView.AttachmentType.IMAGE,
|
||||||
|
getTextColor(context,message),
|
||||||
|
attachment
|
||||||
|
)
|
||||||
|
onContentClick.add {
|
||||||
|
binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message.isOpenGroupInvitation -> {
|
message.isOpenGroupInvitation -> {
|
||||||
hideBody = true
|
hideBody = true
|
||||||
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
|
binding.openGroupInvitationView.root.bind(message, getTextColor(context, message))
|
||||||
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
|
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -239,7 +261,7 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
fun recycle() {
|
fun recycle() {
|
||||||
arrayOf(
|
arrayOf(
|
||||||
binding.deletedMessageView.root,
|
binding.deletedMessageView.root,
|
||||||
binding.untrustedView.root,
|
binding.pendingAttachmentView.root,
|
||||||
binding.voiceMessageView.root,
|
binding.voiceMessageView.root,
|
||||||
binding.openGroupInvitationView.root,
|
binding.openGroupInvitationView.root,
|
||||||
binding.documentView.root,
|
binding.documentView.root,
|
||||||
|
@ -259,7 +259,6 @@ class VisibleMessageView : FrameLayout {
|
|||||||
glide,
|
glide,
|
||||||
thread,
|
thread,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
|
|
||||||
onAttachmentNeedsDownload
|
onAttachmentNeedsDownload
|
||||||
)
|
)
|
||||||
binding.messageContentView.root.delegate = delegate
|
binding.messageContentView.root.delegate = delegate
|
||||||
|
@ -35,6 +35,12 @@ import org.session.libsignal.utilities.Base64;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import kotlin.Unit;
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow;
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow;
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow;
|
||||||
|
import kotlinx.coroutines.flow.SharedFlowKt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility class for working with identity keys.
|
* Utility class for working with identity keys.
|
||||||
*
|
*
|
||||||
@ -56,6 +62,8 @@ public class IdentityKeyUtil {
|
|||||||
public static final String LOKI_SEED = "loki_seed";
|
public static final String LOKI_SEED = "loki_seed";
|
||||||
public static final String HAS_MIGRATED_KEY = "has_migrated_keys";
|
public static final String HAS_MIGRATED_KEY = "has_migrated_keys";
|
||||||
|
|
||||||
|
public static final MutableSharedFlow<Unit> CHANGES = SharedFlowKt.MutableSharedFlow(0, 1, BufferOverflow.DROP_LATEST);
|
||||||
|
|
||||||
private static SharedPreferences getSharedPreferences(Context context) {
|
private static SharedPreferences getSharedPreferences(Context context) {
|
||||||
return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
|
return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
|
||||||
}
|
}
|
||||||
@ -158,9 +166,11 @@ public class IdentityKeyUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences");
|
if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences");
|
||||||
|
CHANGES.tryEmit(Unit.INSTANCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void delete(Context context, String key) {
|
public static void delete(Context context, String key) {
|
||||||
context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0).edit().remove(key).commit();
|
context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0).edit().remove(key).commit();
|
||||||
|
CHANGES.tryEmit(Unit.INSTANCE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,9 @@ import android.content.Context
|
|||||||
import androidx.core.content.contentValuesOf
|
import androidx.core.content.contentValuesOf
|
||||||
import androidx.core.database.getBlobOrNull
|
import androidx.core.database.getBlobOrNull
|
||||||
import androidx.core.database.getLongOrNull
|
import androidx.core.database.getLongOrNull
|
||||||
|
import androidx.sqlite.db.transaction
|
||||||
|
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
|
||||||
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||||
|
|
||||||
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
|
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
|
||||||
@ -20,6 +23,11 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
|
|||||||
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
|
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
|
||||||
|
|
||||||
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
|
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
|
||||||
|
private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?"
|
||||||
|
|
||||||
|
val KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name
|
||||||
|
val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name
|
||||||
|
val MEMBER_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name
|
||||||
}
|
}
|
||||||
|
|
||||||
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
|
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
|
||||||
@ -33,6 +41,49 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
|
|||||||
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
|
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteGroupConfigs(closedGroupId: AccountId) {
|
||||||
|
val db = writableDatabase
|
||||||
|
db.transaction {
|
||||||
|
val variants = arrayOf(KEYS_VARIANT, INFO_VARIANT, MEMBER_VARIANT)
|
||||||
|
db.delete(TABLE_NAME, VARIANT_IN_AND_PUBKEY_WHERE,
|
||||||
|
arrayOf(variants, closedGroupId.hexString)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) {
|
||||||
|
val db = writableDatabase
|
||||||
|
db.transaction {
|
||||||
|
val keyContent = contentValuesOf(
|
||||||
|
VARIANT to KEYS_VARIANT,
|
||||||
|
PUBKEY to publicKey,
|
||||||
|
DATA to keysConfig,
|
||||||
|
TIMESTAMP to timestamp
|
||||||
|
)
|
||||||
|
db.insertOrUpdate(TABLE_NAME, keyContent, VARIANT_AND_PUBKEY_WHERE,
|
||||||
|
arrayOf(KEYS_VARIANT, publicKey)
|
||||||
|
)
|
||||||
|
val infoContent = contentValuesOf(
|
||||||
|
VARIANT to INFO_VARIANT,
|
||||||
|
PUBKEY to publicKey,
|
||||||
|
DATA to infoConfig,
|
||||||
|
TIMESTAMP to timestamp
|
||||||
|
)
|
||||||
|
db.insertOrUpdate(TABLE_NAME, infoContent, VARIANT_AND_PUBKEY_WHERE,
|
||||||
|
arrayOf(INFO_VARIANT, publicKey)
|
||||||
|
)
|
||||||
|
val memberContent = contentValuesOf(
|
||||||
|
VARIANT to MEMBER_VARIANT,
|
||||||
|
PUBKEY to publicKey,
|
||||||
|
DATA to memberConfig,
|
||||||
|
TIMESTAMP to timestamp
|
||||||
|
)
|
||||||
|
db.insertOrUpdate(TABLE_NAME, memberContent, VARIANT_AND_PUBKEY_WHERE,
|
||||||
|
arrayOf(MEMBER_VARIANT, publicKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
|
fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
|
||||||
val db = readableDatabase
|
val db = readableDatabase
|
||||||
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
|
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
|
||||||
|
@ -5,7 +5,7 @@ import android.content.Context
|
|||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata
|
import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata
|
||||||
import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX
|
import org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX
|
||||||
import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX
|
import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX
|
||||||
import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX
|
import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||||
@ -29,7 +29,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
|
|||||||
val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """
|
val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """
|
||||||
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
|
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
|
||||||
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
|
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
|
||||||
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%'
|
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$LEGACY_CLOSED_GROUP_PREFIX%'
|
||||||
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
|
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
|
|||||||
val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """
|
val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """
|
||||||
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
|
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
|
||||||
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
|
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
|
||||||
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%'
|
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$LEGACY_CLOSED_GROUP_PREFIX%'
|
||||||
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%'
|
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%'
|
||||||
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%'
|
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%'
|
||||||
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
|
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
|
||||||
|
@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
|
|||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
|
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
|
||||||
|
import org.session.libsession.database.ServerHashToMessageId
|
||||||
import org.session.libsignal.database.LokiMessageDatabaseProtocol
|
import org.session.libsignal.database.LokiMessageDatabaseProtocol
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||||
@ -16,6 +17,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
|||||||
private val messageHashTable = "loki_message_hash_database"
|
private val messageHashTable = "loki_message_hash_database"
|
||||||
private val smsHashTable = "loki_sms_hash_database"
|
private val smsHashTable = "loki_sms_hash_database"
|
||||||
private val mmsHashTable = "loki_mms_hash_database"
|
private val mmsHashTable = "loki_mms_hash_database"
|
||||||
|
const val groupInviteTable = "loki_group_invites"
|
||||||
|
|
||||||
|
private val groupInviteDeleteTrigger = "group_invite_delete_trigger"
|
||||||
|
|
||||||
private val messageID = "message_id"
|
private val messageID = "message_id"
|
||||||
private val serverID = "server_id"
|
private val serverID = "server_id"
|
||||||
private val friendRequestStatus = "friend_request_status"
|
private val friendRequestStatus = "friend_request_status"
|
||||||
@ -23,6 +28,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
|||||||
private val errorMessage = "error_message"
|
private val errorMessage = "error_message"
|
||||||
private val messageType = "message_type"
|
private val messageType = "message_type"
|
||||||
private val serverHash = "server_hash"
|
private val serverHash = "server_hash"
|
||||||
|
const val invitingSessionId = "inviting_session_id"
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);"
|
val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);"
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@ -39,6 +46,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
|||||||
val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
|
val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
|
val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
|
||||||
|
@JvmStatic
|
||||||
|
val createGroupInviteTableCommand = "CREATE TABLE IF NOT EXISTS $groupInviteTable ($threadID INTEGER PRIMARY KEY, $invitingSessionId STRING);"
|
||||||
|
@JvmStatic
|
||||||
|
val createThreadDeleteTrigger = "CREATE TRIGGER IF NOT EXISTS $groupInviteDeleteTrigger AFTER DELETE ON ${ThreadDatabase.TABLE_NAME} BEGIN DELETE FROM $groupInviteTable WHERE $threadID = OLD.${ThreadDatabase.ID}; END;"
|
||||||
|
|
||||||
const val SMS_TYPE = 0
|
const val SMS_TYPE = 0
|
||||||
const val MMS_TYPE = 1
|
const val MMS_TYPE = 1
|
||||||
@ -224,6 +235,49 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSendersForHashes(threadId: Long, hashes: Set<String>): List<ServerHashToMessageId> {
|
||||||
|
val smsQuery = "SELECT ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ADDRESS}, $smsHashTable.$serverHash, " +
|
||||||
|
"${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} FROM $smsHashTable LEFT OUTER JOIN ${SmsDatabase.TABLE_NAME} " +
|
||||||
|
"ON ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} = $smsHashTable.$messageID WHERE ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.THREAD_ID} = ?;"
|
||||||
|
val mmsQuery = "SELECT ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ADDRESS}, $mmsHashTable.$serverHash, " +
|
||||||
|
"${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} FROM $mmsHashTable LEFT OUTER JOIN ${MmsDatabase.TABLE_NAME} " +
|
||||||
|
"ON ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} = $mmsHashTable.$messageID WHERE ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.THREAD_ID} = ?;"
|
||||||
|
val smsCursor = databaseHelper.readableDatabase.query(smsQuery, arrayOf(threadId))
|
||||||
|
val mmsCursor = databaseHelper.readableDatabase.query(mmsQuery, arrayOf(threadId))
|
||||||
|
|
||||||
|
val serverHashToMessageIds = mutableListOf<ServerHashToMessageId>()
|
||||||
|
|
||||||
|
smsCursor.use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val hash = cursor.getString(1)
|
||||||
|
if (hash in hashes) {
|
||||||
|
serverHashToMessageIds += ServerHashToMessageId(
|
||||||
|
serverHash = hash,
|
||||||
|
isSms = true,
|
||||||
|
sender = cursor.getString(0),
|
||||||
|
messageId = cursor.getLong(2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mmsCursor.use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val hash = cursor.getString(1)
|
||||||
|
if (hash in hashes) {
|
||||||
|
serverHashToMessageIds += ServerHashToMessageId(
|
||||||
|
serverHash = hash,
|
||||||
|
isSms = false,
|
||||||
|
sender = cursor.getString(0),
|
||||||
|
messageId = cursor.getLong(2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverHashToMessageIds
|
||||||
|
}
|
||||||
|
|
||||||
fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull {
|
fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull {
|
||||||
databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
|
databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
|
||||||
cursor.getString(serverHash)
|
cursor.getString(serverHash)
|
||||||
@ -255,6 +309,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addGroupInviteReferrer(groupThreadId: Long, referrerSessionId: String) {
|
||||||
|
val contentValues = ContentValues(2).apply {
|
||||||
|
put(threadID, groupThreadId)
|
||||||
|
put(invitingSessionId, referrerSessionId)
|
||||||
|
}
|
||||||
|
databaseHelper.writableDatabase.insertOrUpdate(
|
||||||
|
groupInviteTable, contentValues, "$threadID = ?", arrayOf(groupThreadId.toString())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupInviteReferrer(groupThreadId: Long): String? {
|
||||||
|
return databaseHelper.readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) {cursor ->
|
||||||
|
cursor.getString(invitingSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGroupInviteReferrer(groupThreadId: Long) {
|
||||||
|
databaseHelper.writableDatabase.delete(
|
||||||
|
groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())
|
||||||
|
)
|
||||||
|
}
|
||||||
private fun getMessageTables(mms: Boolean) = sequenceOf(
|
private fun getMessageTables(mms: Boolean) = sequenceOf(
|
||||||
getMessageTable(mms),
|
getMessageTable(mms),
|
||||||
messageHashTable
|
messageHashTable
|
||||||
|
@ -44,7 +44,8 @@ public class MediaDatabase extends Database {
|
|||||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
|
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
|
||||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
|
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
|
||||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", "
|
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", "
|
||||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + " "
|
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + ", "
|
||||||
|
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.LINK_PREVIEWS + " "
|
||||||
+ "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
|
+ "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
|
||||||
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
|
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
|
||||||
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID
|
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID
|
||||||
@ -52,7 +53,8 @@ public class MediaDatabase extends Database {
|
|||||||
+ " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND "
|
+ " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND "
|
||||||
+ AttachmentDatabase.DATA + " IS NOT NULL AND "
|
+ AttachmentDatabase.DATA + " IS NOT NULL AND "
|
||||||
+ AttachmentDatabase.QUOTE + " = 0 AND "
|
+ AttachmentDatabase.QUOTE + " = 0 AND "
|
||||||
+ AttachmentDatabase.STICKER_PACK_ID + " IS NULL "
|
+ AttachmentDatabase.STICKER_PACK_ID + " IS NULL AND "
|
||||||
|
+ MmsDatabase.LINK_PREVIEWS + " IS NULL "
|
||||||
+ "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC";
|
+ "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC";
|
||||||
|
|
||||||
private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'");
|
private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'");
|
||||||
|
@ -14,7 +14,6 @@ import org.session.libsession.utilities.IdentityKeyMismatchList;
|
|||||||
import org.session.libsignal.crypto.IdentityKey;
|
import org.session.libsignal.crypto.IdentityKey;
|
||||||
import org.session.libsignal.utilities.JsonUtil;
|
import org.session.libsignal.utilities.JsonUtil;
|
||||||
import org.session.libsignal.utilities.Log;
|
import org.session.libsignal.utilities.Log;
|
||||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType;
|
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||||
@ -46,7 +45,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
|||||||
|
|
||||||
public abstract void markUnidentified(long messageId, boolean unidentified);
|
public abstract void markUnidentified(long messageId, boolean unidentified);
|
||||||
|
|
||||||
public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention);
|
public abstract void markAsDeleted(long messageId);
|
||||||
|
|
||||||
public abstract boolean deleteMessage(long messageId);
|
public abstract boolean deleteMessage(long messageId);
|
||||||
public abstract boolean deleteMessages(long[] messageId, long threadId);
|
public abstract boolean deleteMessages(long[] messageId, long threadId);
|
||||||
@ -55,6 +54,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
|||||||
|
|
||||||
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
|
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
|
||||||
|
|
||||||
|
public abstract String getTypeColumn();
|
||||||
|
|
||||||
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
|
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
|
||||||
try {
|
try {
|
||||||
addToDocument(messageId, MISMATCHED_IDENTITIES,
|
addToDocument(messageId, MISMATCHED_IDENTITIES,
|
||||||
@ -206,6 +207,19 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
|||||||
contentValues.put(THREAD_ID, newThreadId);
|
contentValues.put(THREAD_ID, newThreadId);
|
||||||
db.update(getTableName(), contentValues, where, args);
|
db.update(getTableName(), contentValues, where, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isOutgoing(long messageId) {
|
||||||
|
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||||
|
try(Cursor cursor = db.query(getTableName(), new String[]{getTypeColumn()},
|
||||||
|
ID_WHERE, new String[]{String.valueOf(messageId)},
|
||||||
|
null, null, null)) {
|
||||||
|
if (cursor != null && cursor.moveToNext()) {
|
||||||
|
return MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static class SyncMessageId {
|
public static class SyncMessageId {
|
||||||
|
|
||||||
private final Address address;
|
private final Address address;
|
||||||
|
@ -158,7 +158,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
)
|
)
|
||||||
get(context).groupReceiptDatabase()
|
get(context).groupReceiptDatabase()
|
||||||
.update(ourAddress, id, status, timestamp)
|
.update(ourAddress, id, status, timestamp)
|
||||||
get(context).threadDatabase().update(threadId, false, true)
|
get(context).threadDatabase().update(threadId, false)
|
||||||
notifyConversationListeners(threadId)
|
notifyConversationListeners(threadId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -178,6 +178,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateInfoMessage(messageId: Long, body: String?, runThreadUpdate: Boolean = true) {
|
||||||
|
val threadId = getThreadIdForMessage(messageId)
|
||||||
|
val db = databaseHelper.writableDatabase
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE $TABLE_NAME SET $BODY = ? WHERE $ID = ?",
|
||||||
|
arrayOf(body, messageId.toString())
|
||||||
|
)
|
||||||
|
with (get(context).threadDatabase()) {
|
||||||
|
setLastSeen(threadId)
|
||||||
|
setHasSent(threadId, true)
|
||||||
|
if (runThreadUpdate) {
|
||||||
|
update(threadId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) {
|
fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) {
|
||||||
val db = databaseHelper.writableDatabase
|
val db = databaseHelper.writableDatabase
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
@ -257,7 +273,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
" WHERE " + ID + " = ?", arrayOf(id.toString() + "")
|
" WHERE " + ID + " = ?", arrayOf(id.toString() + "")
|
||||||
)
|
)
|
||||||
if (threadId.isPresent) {
|
if (threadId.isPresent) {
|
||||||
get(context).threadDatabase().update(threadId.get(), false, true)
|
get(context).threadDatabase().update(threadId.get(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,7 +320,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString()))
|
db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString()))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) {
|
override fun markAsDeleted(messageId: Long) {
|
||||||
val database = databaseHelper.writableDatabase
|
val database = databaseHelper.writableDatabase
|
||||||
val contentValues = ContentValues()
|
val contentValues = ContentValues()
|
||||||
contentValues.put(READ, 1)
|
contentValues.put(READ, 1)
|
||||||
@ -626,7 +642,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
)
|
)
|
||||||
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
|
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
|
||||||
if (runThreadUpdate) {
|
if (runThreadUpdate) {
|
||||||
get(context).threadDatabase().update(threadId, true, true)
|
get(context).threadDatabase().update(threadId, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifyConversationListeners(threadId)
|
notifyConversationListeners(threadId)
|
||||||
@ -771,7 +787,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
}
|
}
|
||||||
setHasSent(threadId, true)
|
setHasSent(threadId, true)
|
||||||
if (runThreadUpdate) {
|
if (runThreadUpdate) {
|
||||||
update(threadId, true, true)
|
update(threadId, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return messageId
|
return messageId
|
||||||
@ -851,23 +867,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteQuotedFromMessages(toDeleteRecords: List<MessageRecord>) {
|
|
||||||
if (toDeleteRecords.isEmpty()) return
|
|
||||||
val queryBuilder = StringBuilder()
|
|
||||||
for (i in toDeleteRecords.indices) {
|
|
||||||
queryBuilder.append("$QUOTE_ID = ").append(toDeleteRecords[i].getId())
|
|
||||||
if (i + 1 < toDeleteRecords.size) {
|
|
||||||
queryBuilder.append(" OR ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val query = queryBuilder.toString()
|
|
||||||
val db = databaseHelper.writableDatabase
|
|
||||||
val values = ContentValues(2)
|
|
||||||
values.put(QUOTE_MISSING, 1)
|
|
||||||
values.put(QUOTE_AUTHOR, "")
|
|
||||||
db!!.update(TABLE_NAME, values, query, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all the messages in single queries where possible
|
* Delete all the messages in single queries where possible
|
||||||
* @param messageIds a String array representation of regularly Long types representing message IDs
|
* @param messageIds a String array representation of regularly Long types representing message IDs
|
||||||
@ -900,6 +899,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
notifyStickerPackListeners()
|
notifyStickerPackListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getTypeColumn(): String = MESSAGE_BOX
|
||||||
|
|
||||||
// Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
|
// Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
|
||||||
// - it is "Was the thread deleted because removing that message resulted in an empty thread"!
|
// - it is "Was the thread deleted because removing that message resulted in an empty thread"!
|
||||||
override fun deleteMessage(messageId: Long): Boolean {
|
override fun deleteMessage(messageId: Long): Boolean {
|
||||||
@ -909,8 +910,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
val groupReceiptDatabase = get(context).groupReceiptDatabase()
|
val groupReceiptDatabase = get(context).groupReceiptDatabase()
|
||||||
groupReceiptDatabase.deleteRowsForMessage(messageId)
|
groupReceiptDatabase.deleteRowsForMessage(messageId)
|
||||||
val database = databaseHelper.writableDatabase
|
val database = databaseHelper.writableDatabase
|
||||||
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
|
database.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
|
||||||
val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
|
val threadDeleted = get(context).threadDatabase().update(threadId, false)
|
||||||
notifyConversationListeners(threadId)
|
notifyConversationListeners(threadId)
|
||||||
notifyStickerListeners()
|
notifyStickerListeners()
|
||||||
notifyStickerPackListeners()
|
notifyStickerPackListeners()
|
||||||
@ -921,6 +922,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
val argsArray = messageIds.map { "?" }
|
val argsArray = messageIds.map { "?" }
|
||||||
val argValues = messageIds.map { it.toString() }.toTypedArray()
|
val argValues = messageIds.map { it.toString() }.toTypedArray()
|
||||||
|
|
||||||
|
val attachmentDatabase = get(context).attachmentDatabase()
|
||||||
|
val groupReceiptDatabase = get(context).groupReceiptDatabase()
|
||||||
|
|
||||||
|
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
|
||||||
|
groupReceiptDatabase.deleteRowsForMessages(messageIds)
|
||||||
|
|
||||||
val db = databaseHelper.writableDatabase
|
val db = databaseHelper.writableDatabase
|
||||||
db.delete(
|
db.delete(
|
||||||
TABLE_NAME,
|
TABLE_NAME,
|
||||||
@ -928,7 +935,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
argValues
|
argValues
|
||||||
)
|
)
|
||||||
|
|
||||||
val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
|
val threadDeleted = get(context).threadDatabase().update(threadId, false)
|
||||||
notifyConversationListeners(threadId)
|
notifyConversationListeners(threadId)
|
||||||
notifyStickerListeners()
|
notifyStickerListeners()
|
||||||
notifyStickerPackListeners()
|
notifyStickerPackListeners()
|
||||||
@ -956,6 +963,62 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
deleteThreads(setOf(threadId))
|
deleteThreads(setOf(threadId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteMediaFor(threadId: Long, fromUser: String? = null) {
|
||||||
|
val db = databaseHelper.writableDatabase
|
||||||
|
val whereString =
|
||||||
|
if (fromUser == null) "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL"
|
||||||
|
else "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL"
|
||||||
|
val whereArgs = if (fromUser == null) arrayOf(threadId.toString()) else arrayOf(threadId.toString(), fromUser)
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
try {
|
||||||
|
cursor = db.query(TABLE_NAME, arrayOf(ID), whereString, whereArgs, null, null, null, null)
|
||||||
|
val toDeleteStringMessageIds = mutableListOf<String>()
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
|
||||||
|
}
|
||||||
|
// TODO: this can probably be optimized out,
|
||||||
|
// currently attachmentDB uses MmsID not threadID which makes it difficult to delete
|
||||||
|
// and clean up on threadID alone
|
||||||
|
toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
|
||||||
|
deleteMessages(sublist.toTypedArray())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
val threadDb = get(context).threadDatabase()
|
||||||
|
threadDb.update(threadId, false)
|
||||||
|
notifyConversationListeners(threadId)
|
||||||
|
notifyStickerListeners()
|
||||||
|
notifyStickerPackListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessagesFrom(threadId: Long, fromUser: String) { // copied from deleteThreads implementation
|
||||||
|
val db = databaseHelper.writableDatabase
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
val whereString = "$THREAD_ID = ? AND $ADDRESS = ?"
|
||||||
|
try {
|
||||||
|
cursor =
|
||||||
|
db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null)
|
||||||
|
val toDeleteStringMessageIds = mutableListOf<String>()
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
|
||||||
|
}
|
||||||
|
// TODO: this can probably be optimized out,
|
||||||
|
// currently attachmentDB uses MmsID not threadID which makes it difficult to delete
|
||||||
|
// and clean up on threadID alone
|
||||||
|
toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
|
||||||
|
deleteMessages(sublist.toTypedArray())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
val threadDb = get(context).threadDatabase()
|
||||||
|
threadDb.update(threadId, false)
|
||||||
|
notifyConversationListeners(threadId)
|
||||||
|
notifyStickerListeners()
|
||||||
|
notifyStickerPackListeners()
|
||||||
|
}
|
||||||
|
|
||||||
private fun getSerializedSharedContacts(
|
private fun getSerializedSharedContacts(
|
||||||
insertedAttachmentIds: Map<Attachment?, AttachmentId?>,
|
insertedAttachmentIds: Map<Attachment?, AttachmentId?>,
|
||||||
contacts: List<Contact?>
|
contacts: List<Contact?>
|
||||||
@ -1099,7 +1162,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/*package*/
|
|
||||||
private fun deleteThreads(threadIds: Set<Long>) {
|
private fun deleteThreads(threadIds: Set<Long>) {
|
||||||
val db = databaseHelper.writableDatabase
|
val db = databaseHelper.writableDatabase
|
||||||
val where = StringBuilder()
|
val where = StringBuilder()
|
||||||
@ -1125,7 +1187,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
}
|
}
|
||||||
val threadDb = get(context).threadDatabase()
|
val threadDb = get(context).threadDatabase()
|
||||||
for (threadId in threadIds) {
|
for (threadId in threadIds) {
|
||||||
val threadDeleted = threadDb.update(threadId, false, true)
|
val threadDeleted = threadDb.update(threadId, false)
|
||||||
notifyConversationListeners(threadId)
|
notifyConversationListeners(threadId)
|
||||||
}
|
}
|
||||||
notifyStickerListeners()
|
notifyStickerListeners()
|
||||||
@ -1133,17 +1195,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*package*/
|
/*package*/
|
||||||
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long) {
|
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, onlyMedia: Boolean) {
|
||||||
var cursor: Cursor? = null
|
var cursor: Cursor? = null
|
||||||
try {
|
try {
|
||||||
val db = databaseHelper.readableDatabase
|
val db = databaseHelper.readableDatabase
|
||||||
var where =
|
var where = "$THREAD_ID = ? AND $DATE_SENT < $date"
|
||||||
THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") "
|
if (onlyMedia) where += " AND $PART_COUNT >= 1"
|
||||||
for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) {
|
cursor = db.query(
|
||||||
where += " WHEN $outgoingType THEN $DATE_SENT < $date"
|
|
||||||
}
|
|
||||||
where += " ELSE $DATE_RECEIVED < $date END)"
|
|
||||||
cursor = db!!.query(
|
|
||||||
TABLE_NAME,
|
TABLE_NAME,
|
||||||
arrayOf<String?>(ID),
|
arrayOf<String?>(ID),
|
||||||
where,
|
where,
|
||||||
|
@ -37,7 +37,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
|||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||||
|
|
||||||
import java.io.Closeable;
|
import java.io.Closeable;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import kotlin.Pair;
|
import kotlin.Pair;
|
||||||
@ -261,6 +263,23 @@ public class MmsSmsDatabase extends Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<MessageRecord> getUserMessages(long threadId, String sender) {
|
||||||
|
|
||||||
|
List<MessageRecord> idList = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Cursor cursor = getConversation(threadId, false)) {
|
||||||
|
Reader reader = readerFor(cursor);
|
||||||
|
while (reader.getNext() != null) {
|
||||||
|
MessageRecord record = reader.getCurrent();
|
||||||
|
if (record.getIndividualRecipient().getAddress().serialize().equals(sender)) {
|
||||||
|
idList.add(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idList;
|
||||||
|
}
|
||||||
|
|
||||||
// Builds up and returns a list of all all the messages sent by this user in the given thread.
|
// Builds up and returns a list of all all the messages sent by this user in the given thread.
|
||||||
// Used to do a pass through our local database to remove records when a user has "Ban & Delete"
|
// Used to do a pass through our local database to remove records when a user has "Ban & Delete"
|
||||||
// called on them in a Community.
|
// called on them in a Community.
|
||||||
|
@ -65,13 +65,14 @@ public class RecipientDatabase extends Database {
|
|||||||
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
|
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
|
||||||
private static final String WRAPPER_HASH = "wrapper_hash";
|
private static final String WRAPPER_HASH = "wrapper_hash";
|
||||||
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
|
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
|
||||||
|
private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference
|
||||||
|
|
||||||
private static final String[] RECIPIENT_PROJECTION = new String[] {
|
private static final String[] RECIPIENT_PROJECTION = new String[] {
|
||||||
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
|
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
|
||||||
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
|
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
|
||||||
SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
|
SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
|
||||||
UNIDENTIFIED_ACCESS_MODE,
|
UNIDENTIFIED_ACCESS_MODE,
|
||||||
FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
|
FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD,
|
||||||
};
|
};
|
||||||
|
|
||||||
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
|
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
|
||||||
@ -110,6 +111,17 @@ public class RecipientDatabase extends Database {
|
|||||||
"ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;";
|
"ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String getCreateAutoDownloadCommand() {
|
||||||
|
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||||
|
"ADD COLUMN " + AUTO_DOWNLOAD + " INTEGER DEFAULT -1;";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getUpdateAutoDownloadValuesCommand() {
|
||||||
|
return "UPDATE "+TABLE_NAME+" SET "+AUTO_DOWNLOAD+" = 1 "+
|
||||||
|
"WHERE "+ADDRESS+" IN (SELECT "+SessionContactDatabase.sessionContactTable+"."+SessionContactDatabase.accountID+" "+
|
||||||
|
"FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))";
|
||||||
|
}
|
||||||
|
|
||||||
public static String getCreateApprovedCommand() {
|
public static String getCreateApprovedCommand() {
|
||||||
return "ALTER TABLE "+ TABLE_NAME + " " +
|
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||||
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
|
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
|
||||||
@ -194,6 +206,7 @@ public class RecipientDatabase extends Database {
|
|||||||
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
|
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
|
||||||
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
|
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
|
||||||
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
|
int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
|
||||||
|
boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1;
|
||||||
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
|
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
|
||||||
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
|
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
|
||||||
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
|
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
|
||||||
@ -232,7 +245,7 @@ public class RecipientDatabase extends Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
|
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
|
||||||
notifyType,
|
notifyType, autoDownloadAttachments,
|
||||||
Recipient.DisappearingState.fromId(disappearingState),
|
Recipient.DisappearingState.fromId(disappearingState),
|
||||||
Recipient.VibrateState.fromId(messageVibrateState),
|
Recipient.VibrateState.fromId(messageVibrateState),
|
||||||
Recipient.VibrateState.fromId(callVibrateState),
|
Recipient.VibrateState.fromId(callVibrateState),
|
||||||
@ -246,6 +259,22 @@ public class RecipientDatabase extends Database {
|
|||||||
forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
|
forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isAutoDownloadFlagSet(Recipient recipient) {
|
||||||
|
SQLiteDatabase db = getReadableDatabase();
|
||||||
|
Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().serialize() }, null, null, null);
|
||||||
|
boolean flagUnset = false;
|
||||||
|
try {
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
// flag isn't set if it is -1
|
||||||
|
flagUnset = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == -1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
// negate result (is flag set)
|
||||||
|
return !flagUnset;
|
||||||
|
}
|
||||||
|
|
||||||
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
|
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
|
||||||
ContentValues values = new ContentValues();
|
ContentValues values = new ContentValues();
|
||||||
values.put(COLOR, color.serialize());
|
values.put(COLOR, color.serialize());
|
||||||
@ -321,6 +350,21 @@ public class RecipientDatabase extends Database {
|
|||||||
notifyRecipientListeners();
|
notifyRecipientListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setAutoDownloadAttachments(@NonNull Recipient recipient, boolean shouldAutoDownloadAttachments) {
|
||||||
|
SQLiteDatabase db = getWritableDatabase();
|
||||||
|
db.beginTransaction();
|
||||||
|
try {
|
||||||
|
ContentValues values = new ContentValues();
|
||||||
|
values.put(AUTO_DOWNLOAD, shouldAutoDownloadAttachments ? 1 : 0);
|
||||||
|
db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().serialize()});
|
||||||
|
recipient.resolve().setAutoDownloadAttachments(shouldAutoDownloadAttachments);
|
||||||
|
db.setTransactionSuccessful();
|
||||||
|
} finally {
|
||||||
|
db.endTransaction();
|
||||||
|
}
|
||||||
|
notifyRecipientListeners();
|
||||||
|
}
|
||||||
|
|
||||||
public void setMuted(@NonNull Recipient recipient, long until) {
|
public void setMuted(@NonNull Recipient recipient, long until) {
|
||||||
ContentValues values = new ContentValues();
|
ContentValues values = new ContentValues();
|
||||||
values.put(MUTE_UNTIL, until);
|
values.put(MUTE_UNTIL, until);
|
||||||
|
@ -3,10 +3,9 @@ package org.thoughtcrime.securesms.database
|
|||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import androidx.core.database.getStringOrNull
|
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.utilities.AccountId
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.session.libsignal.utilities.Base64
|
import org.session.libsignal.utilities.Base64
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||||
@ -14,7 +13,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
|||||||
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val sessionContactTable = "session_contact_database"
|
const val sessionContactTable = "session_contact_database"
|
||||||
const val accountID = "session_id"
|
const val accountID = "session_id"
|
||||||
const val name = "name"
|
const val name = "name"
|
||||||
const val nickname = "nickname"
|
const val nickname = "nickname"
|
||||||
@ -83,23 +82,21 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
|
|||||||
contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it))
|
contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it))
|
||||||
}
|
}
|
||||||
contentValues.put(threadID, contact.threadID)
|
contentValues.put(threadID, contact.threadID)
|
||||||
contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
|
|
||||||
database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
|
database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
|
||||||
notifyConversationListListeners()
|
notifyConversationListListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun contactFromCursor(cursor: Cursor): Contact {
|
fun contactFromCursor(cursor: Cursor): Contact {
|
||||||
val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID))
|
val sessionID = cursor.getString(accountID)
|
||||||
val contact = Contact(accountID)
|
val contact = Contact(sessionID)
|
||||||
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
|
contact.name = cursor.getStringOrNull(name)
|
||||||
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
|
contact.nickname = cursor.getStringOrNull(nickname)
|
||||||
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL))
|
contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
|
||||||
contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName))
|
contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
|
||||||
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let {
|
cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
|
||||||
contact.profilePictureEncryptionKey = Base64.decode(it)
|
contact.profilePictureEncryptionKey = Base64.decode(it)
|
||||||
}
|
}
|
||||||
contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID))
|
contact.threadID = cursor.getLong(threadID)
|
||||||
contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0
|
|
||||||
return contact
|
return contact
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
|||||||
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||||
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
||||||
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
|
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
|
||||||
|
import org.session.libsession.messaging.jobs.InviteContactsJob
|
||||||
import org.session.libsession.messaging.jobs.Job
|
import org.session.libsession.messaging.jobs.Job
|
||||||
import org.session.libsession.messaging.jobs.MessageReceiveJob
|
import org.session.libsession.messaging.jobs.MessageReceiveJob
|
||||||
import org.session.libsession.messaging.jobs.MessageSendJob
|
import org.session.libsession.messaging.jobs.MessageSendJob
|
||||||
@ -78,6 +79,13 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
|
|||||||
return result.firstOrNull { job -> job.attachmentID == attachmentID }
|
return result.firstOrNull { job -> job.attachmentID == attachmentID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getGroupInviteJob(groupSessionId: String, memberSessionId: String): InviteContactsJob? {
|
||||||
|
val database = databaseHelper.readableDatabase
|
||||||
|
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(InviteContactsJob.KEY)) { cursor ->
|
||||||
|
jobFromCursor(cursor) as? InviteContactsJob
|
||||||
|
}.firstOrNull { it != null && it.groupSessionId == groupSessionId && it.memberSessionIds.contains(memberSessionId) }
|
||||||
|
}
|
||||||
|
|
||||||
fun getMessageSendJob(messageSendJobID: String): MessageSendJob? {
|
fun getMessageSendJob(messageSendJobID: String): MessageSendJob? {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor ->
|
return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor ->
|
||||||
|
@ -158,7 +158,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
|
|
||||||
long threadId = getThreadIdForMessage(id);
|
long threadId = getThreadIdForMessage(id);
|
||||||
|
|
||||||
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
|
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,7 +237,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void markAsDeleted(long messageId, boolean read, boolean hasMention) {
|
public void markAsDeleted(long messageId) {
|
||||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
contentValues.put(READ, 1);
|
contentValues.put(READ, 1);
|
||||||
@ -257,7 +257,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
|
|
||||||
long threadId = getThreadIdForMessage(id);
|
long threadId = getThreadIdForMessage(id);
|
||||||
|
|
||||||
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
|
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,6 +296,11 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
return isOutgoing;
|
return isOutgoing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getTypeColumn() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) {
|
public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) {
|
||||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||||
Cursor cursor = null;
|
Cursor cursor = null;
|
||||||
@ -320,7 +325,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
ID + " = ?",
|
ID + " = ?",
|
||||||
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
|
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
|
||||||
|
|
||||||
DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
|
DatabaseComponent.get(context).threadDatabase().update(threadId, false);
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
foundMessage = true;
|
foundMessage = true;
|
||||||
}
|
}
|
||||||
@ -403,7 +408,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
|
|
||||||
long threadId = getThreadIdForMessage(messageId);
|
long threadId = getThreadIdForMessage(messageId);
|
||||||
|
|
||||||
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
|
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
notifyConversationListListeners();
|
notifyConversationListListeners();
|
||||||
|
|
||||||
@ -478,7 +483,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
long messageId = db.insert(TABLE_NAME, null, values);
|
long messageId = db.insert(TABLE_NAME, null, values);
|
||||||
|
|
||||||
if (runThreadUpdate) {
|
if (runThreadUpdate) {
|
||||||
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
|
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.getSubscriptionId() != -1) {
|
if (message.getSubscriptionId() != -1) {
|
||||||
@ -570,7 +575,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runThreadUpdate) {
|
if (runThreadUpdate) {
|
||||||
DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
|
DatabaseComponent.get(context).threadDatabase().update(threadId, true);
|
||||||
}
|
}
|
||||||
long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first();
|
long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first();
|
||||||
if (lastSeen < message.getSentTimestampMillis()) {
|
if (lastSeen < message.getSentTimestampMillis()) {
|
||||||
@ -630,7 +635,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
long threadId = getThreadIdForMessage(messageId);
|
long threadId = getThreadIdForMessage(messageId);
|
||||||
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
|
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false);
|
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
|
||||||
return threadDeleted;
|
return threadDeleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -650,7 +655,7 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
|
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
|
||||||
argValues
|
argValues
|
||||||
);
|
);
|
||||||
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
|
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
return threadDeleted;
|
return threadDeleted;
|
||||||
}
|
}
|
||||||
@ -697,15 +702,14 @@ public class SmsDatabase extends MessagingDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteMessagesInThreadBeforeDate(long threadId, long date) {
|
void deleteMessagesFrom(long threadId, String fromUser) {
|
||||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||||
String where = THREAD_ID + " = ? AND (CASE " + TYPE;
|
db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
|
||||||
|
|
||||||
for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) {
|
|
||||||
where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)");
|
void deleteMessagesInThreadBeforeDate(long threadId, long date) {
|
||||||
|
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||||
|
String where = THREAD_ID + " = ? AND " + DATE_SENT + " < " + date;
|
||||||
|
|
||||||
db.delete(TABLE_NAME, where, new String[] {threadId + ""});
|
db.delete(TABLE_NAME, where, new String[] {threadId + ""});
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.thoughtcrime.securesms.database;
|
package org.thoughtcrime.securesms.database;
|
||||||
|
|
||||||
import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX;
|
import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX;
|
||||||
import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
|
import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
|
||||||
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
|
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
|
||||||
|
|
||||||
@ -124,9 +124,14 @@ public class ThreadDatabase extends Database {
|
|||||||
.map(columnName -> TABLE_NAME + "." + columnName)
|
.map(columnName -> TABLE_NAME + "." + columnName)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
private static final List<String> COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION),
|
private static final List<String> COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION =
|
||||||
|
// wew
|
||||||
|
Stream.concat(Stream.concat(Stream.concat(
|
||||||
|
Stream.of(TYPED_THREAD_PROJECTION),
|
||||||
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
|
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
|
||||||
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
|
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)),
|
||||||
|
Stream.of(LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId)
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
public static String getCreatePinnedCommand() {
|
public static String getCreatePinnedCommand() {
|
||||||
@ -279,9 +284,9 @@ public class ThreadDatabase extends Database {
|
|||||||
Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate);
|
Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate);
|
||||||
|
|
||||||
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
|
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
|
||||||
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
|
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate, false);
|
||||||
|
|
||||||
update(threadId, false, true);
|
update(threadId, false);
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -293,8 +298,8 @@ public class ThreadDatabase extends Database {
|
|||||||
public void trimThreadBefore(long threadId, long timestamp) {
|
public void trimThreadBefore(long threadId, long timestamp) {
|
||||||
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
|
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
|
||||||
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
|
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
|
||||||
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
|
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp, false);
|
||||||
update(threadId, false, true);
|
update(threadId, false);
|
||||||
notifyConversationListeners(threadId);
|
notifyConversationListeners(threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -428,32 +433,6 @@ public class ThreadDatabase extends Database {
|
|||||||
return db.rawQuery(query, null);
|
return db.rawQuery(query, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getUnapprovedConversationCount() {
|
|
||||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
|
||||||
Cursor cursor = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
String query = "SELECT COUNT (*) FROM " + TABLE_NAME +
|
|
||||||
" LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
|
|
||||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
|
|
||||||
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
|
|
||||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
|
|
||||||
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
|
|
||||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
|
|
||||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
|
|
||||||
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
|
|
||||||
cursor = db.rawQuery(query, null);
|
|
||||||
|
|
||||||
if (cursor != null && cursor.moveToFirst())
|
|
||||||
return cursor.getInt(0);
|
|
||||||
} finally {
|
|
||||||
if (cursor != null)
|
|
||||||
cursor.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getLatestUnapprovedConversationTimestamp() {
|
public long getLatestUnapprovedConversationTimestamp() {
|
||||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||||
Cursor cursor = null;
|
Cursor cursor = null;
|
||||||
@ -492,13 +471,15 @@ public class ThreadDatabase extends Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Cursor getApprovedConversationList() {
|
public Cursor getApprovedConversationList() {
|
||||||
String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
|
String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+ LEGACY_CLOSED_GROUP_PREFIX +"%') " +
|
||||||
|
"OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
|
||||||
"AND " + ARCHIVED + " = 0 ";
|
"AND " + ARCHIVED + " = 0 ";
|
||||||
return getConversationList(where);
|
return getConversationList(where);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Cursor getUnapprovedConversationList() {
|
public Cursor getUnapprovedConversationList() {
|
||||||
String where = MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
|
String where = "("+MESSAGE_COUNT + " != 0 OR "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" LIKE '"+IdPrefix.GROUP.getValue()+"%')" +
|
||||||
|
" AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
|
||||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
|
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
|
||||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
|
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
|
||||||
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
|
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
|
||||||
@ -722,19 +703,14 @@ public class ThreadDatabase extends Database {
|
|||||||
notifyConversationListListeners();
|
notifyConversationListListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) {
|
public boolean update(long threadId, boolean unarchive) {
|
||||||
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
|
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
|
||||||
long count = mmsSmsDatabase.getConversationCount(threadId);
|
long count = mmsSmsDatabase.getConversationCount(threadId);
|
||||||
|
|
||||||
boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId);
|
MmsSmsDatabase.Reader reader = null;
|
||||||
|
|
||||||
if (count == 0 && shouldDeleteEmptyThread) {
|
try {
|
||||||
deleteThread(threadId);
|
reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId));
|
||||||
notifyConversationListListeners();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) {
|
|
||||||
MessageRecord record = null;
|
MessageRecord record = null;
|
||||||
if (reader != null) {
|
if (reader != null) {
|
||||||
record = reader.getNext();
|
record = reader.getNext();
|
||||||
@ -748,11 +724,7 @@ public class ThreadDatabase extends Database {
|
|||||||
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
|
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
if (shouldDeleteEmptyThread) {
|
updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0);
|
||||||
deleteThread(threadId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// todo: add empty snippet that clears existing data
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -800,9 +772,8 @@ public class ThreadDatabase extends Database {
|
|||||||
return setLastSeen(threadId, lastSeenTime);
|
return setLastSeen(threadId, lastSeenTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean possibleToDeleteThreadOnEmpty(long threadId) {
|
private boolean deleteThreadOnEmpty(long threadId) {
|
||||||
Recipient threadRecipient = getRecipientForThreadId(threadId);
|
return false;
|
||||||
return threadRecipient != null && !threadRecipient.isCommunityRecipient();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
|
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
|
||||||
@ -844,6 +815,8 @@ public class ThreadDatabase extends Database {
|
|||||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
|
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
|
||||||
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
|
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
|
||||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
|
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
|
||||||
|
" LEFT OUTER JOIN " + LokiMessageDatabase.groupInviteTable +
|
||||||
|
" ON "+ TABLE_NAME + "." + ID + " = " + LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId +
|
||||||
" WHERE " + where +
|
" WHERE " + where +
|
||||||
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
|
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
|
||||||
|
|
||||||
@ -923,6 +896,7 @@ public class ThreadDatabase extends Database {
|
|||||||
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
|
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
|
||||||
Uri snippetUri = getSnippetUri(cursor);
|
Uri snippetUri = getSnippetUri(cursor);
|
||||||
boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0;
|
boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0;
|
||||||
|
String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId));
|
||||||
|
|
||||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||||
readReceiptCount = 0;
|
readReceiptCount = 0;
|
||||||
@ -940,7 +914,7 @@ public class ThreadDatabase extends Database {
|
|||||||
|
|
||||||
return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
|
return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
|
||||||
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
|
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
|
||||||
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned);
|
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned, invitingAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
private @Nullable Uri getSnippetUri(Cursor cursor) {
|
private @Nullable Uri getSnippetUri(Cursor cursor) {
|
||||||
|
@ -90,9 +90,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
private static final int lokiV44 = 65;
|
private static final int lokiV44 = 65;
|
||||||
private static final int lokiV45 = 66;
|
private static final int lokiV45 = 66;
|
||||||
private static final int lokiV46 = 67;
|
private static final int lokiV46 = 67;
|
||||||
|
private static final int lokiV47 = 68;
|
||||||
|
private static final int lokiV48 = 69;
|
||||||
|
|
||||||
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
||||||
private static final int DATABASE_VERSION = lokiV46;
|
private static final int DATABASE_VERSION = lokiV48;
|
||||||
private static final int MIN_DATABASE_VERSION = lokiV7;
|
private static final int MIN_DATABASE_VERSION = lokiV7;
|
||||||
private static final String CIPHER3_DATABASE_NAME = "signal.db";
|
private static final String CIPHER3_DATABASE_NAME = "signal.db";
|
||||||
public static final String DATABASE_NAME = "signal_v4.db";
|
public static final String DATABASE_NAME = "signal_v4.db";
|
||||||
@ -362,6 +364,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
db.execSQL(RecipientDatabase.getAddWrapperHash());
|
db.execSQL(RecipientDatabase.getAddWrapperHash());
|
||||||
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
|
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
|
||||||
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
|
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
|
||||||
|
|
||||||
|
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
|
||||||
|
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
|
||||||
|
db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand());
|
||||||
|
db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -628,6 +635,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
|
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < lokiV47) {
|
||||||
|
db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
|
||||||
|
db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < lokiV48) {
|
||||||
|
db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand());
|
||||||
|
db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger());
|
||||||
|
}
|
||||||
|
|
||||||
db.setTransactionSuccessful();
|
db.setTransactionSuccessful();
|
||||||
} finally {
|
} finally {
|
||||||
db.endTransaction();
|
db.endTransaction();
|
||||||
|
@ -118,7 +118,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
|||||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||||
if (isGroupUpdateMessage()) {
|
if (isGroupUpdateMessage()) {
|
||||||
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody());
|
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody());
|
||||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
|
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing(), true));
|
||||||
} else if (isExpirationTimerUpdate()) {
|
} else if (isExpirationTimerUpdate()) {
|
||||||
int seconds = (int) (getExpiresIn() / 1000);
|
int seconds = (int) (getExpiresIn() / 1000);
|
||||||
boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();
|
boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();
|
||||||
|
@ -30,6 +30,9 @@ import android.text.TextUtils;
|
|||||||
import android.text.style.StyleSpan;
|
import android.text.style.StyleSpan;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.utilities.UpdateMessageBuilder;
|
||||||
|
import org.session.libsession.messaging.utilities.UpdateMessageData;
|
||||||
import com.squareup.phrase.Phrase;
|
import com.squareup.phrase.Phrase;
|
||||||
import org.session.libsession.utilities.ExpirationUtil;
|
import org.session.libsession.utilities.ExpirationUtil;
|
||||||
import org.session.libsession.utilities.recipients.Recipient;
|
import org.session.libsession.utilities.recipients.Recipient;
|
||||||
@ -57,13 +60,14 @@ public class ThreadRecord extends DisplayRecord {
|
|||||||
private final long lastSeen;
|
private final long lastSeen;
|
||||||
private final boolean pinned;
|
private final boolean pinned;
|
||||||
private final int initialRecipientHash;
|
private final int initialRecipientHash;
|
||||||
|
private final String invitingAdminId;
|
||||||
private final long dateSent;
|
private final long dateSent;
|
||||||
|
|
||||||
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
|
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
|
||||||
@Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
|
@Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
|
||||||
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
|
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
|
||||||
long snippetType, int distributionType, boolean archived, long expiresIn,
|
long snippetType, int distributionType, boolean archived, long expiresIn,
|
||||||
long lastSeen, int readReceiptCount, boolean pinned)
|
long lastSeen, int readReceiptCount, boolean pinned, String invitingAdminId)
|
||||||
{
|
{
|
||||||
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
|
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
|
||||||
this.snippetUri = snippetUri;
|
this.snippetUri = snippetUri;
|
||||||
@ -77,6 +81,7 @@ public class ThreadRecord extends DisplayRecord {
|
|||||||
this.lastSeen = lastSeen;
|
this.lastSeen = lastSeen;
|
||||||
this.pinned = pinned;
|
this.pinned = pinned;
|
||||||
this.initialRecipientHash = recipient.hashCode();
|
this.initialRecipientHash = recipient.hashCode();
|
||||||
|
this.invitingAdminId = invitingAdminId;
|
||||||
this.dateSent = date;
|
this.dateSent = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,6 +120,18 @@ public class ThreadRecord extends DisplayRecord {
|
|||||||
@Override
|
@Override
|
||||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||||
if (isGroupUpdateMessage()) {
|
if (isGroupUpdateMessage()) {
|
||||||
|
String body = getBody();
|
||||||
|
if (!body.isEmpty()) {
|
||||||
|
UpdateMessageData updateMessageData = UpdateMessageData.fromJSON(body);
|
||||||
|
if (updateMessageData != null) {
|
||||||
|
return emphasisAdded(
|
||||||
|
UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false)
|
||||||
|
.toString()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return emphasisAdded(context.getString(R.string.groupUpdated));
|
return emphasisAdded(context.getString(R.string.groupUpdated));
|
||||||
} else if (isOpenGroupInvitation()) {
|
} else if (isOpenGroupInvitation()) {
|
||||||
return emphasisAdded(context.getString(R.string.communityInvitation));
|
return emphasisAdded(context.getString(R.string.communityInvitation));
|
||||||
@ -221,4 +238,30 @@ public class ThreadRecord extends DisplayRecord {
|
|||||||
public boolean isPinned() { return pinned; }
|
public boolean isPinned() { return pinned; }
|
||||||
|
|
||||||
public int getInitialRecipientHash() { return initialRecipientHash; }
|
public int getInitialRecipientHash() { return initialRecipientHash; }
|
||||||
|
|
||||||
|
public boolean isLeavingGroup() {
|
||||||
|
if (isGroupUpdateMessage()) {
|
||||||
|
String body = getBody();
|
||||||
|
if (!body.isEmpty()) {
|
||||||
|
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body);
|
||||||
|
return updateMessageData.isGroupLeavingKind();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isErrorLeavingGroup() {
|
||||||
|
if (isGroupUpdateMessage()) {
|
||||||
|
String body = getBody();
|
||||||
|
if (!body.isEmpty()) {
|
||||||
|
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body);
|
||||||
|
return updateMessageData.isGroupErrorQuitKind();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getInvitingAdminId() {
|
||||||
|
return invitingAdminId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,7 @@ class DebugMenuViewModel @Inject constructor(
|
|||||||
// clear remote and local data, then restart the app
|
// clear remote and local data, then restart the app
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get()
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// we can ignore fails here as we might be switching environments before the user gets a public key
|
// we can ignore fails here as we might be switching environments before the user gets a public key
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,20 @@
|
|||||||
package org.thoughtcrime.securesms.dependencies
|
package org.thoughtcrime.securesms.dependencies
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.session.libsession.utilities.AppTextSecurePreferences
|
import org.session.libsession.utilities.AppTextSecurePreferences
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
|
import org.session.libsession.utilities.Toaster
|
||||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
|
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@ -21,6 +27,17 @@ abstract class AppModule {
|
|||||||
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
|
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
class ToasterModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideToaster(@ApplicationContext context: Context) = Toaster { stringRes, toastLength, parameters ->
|
||||||
|
val string = context.getString(stringRes, parameters)
|
||||||
|
Toast.makeText(context, string, toastLength).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@EntryPoint
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface AppComponent {
|
interface AppComponent {
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
package org.thoughtcrime.securesms.dependencies
|
package org.thoughtcrime.securesms.dependencies
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.components.ServiceComponent
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.android.scopes.ServiceScoped
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.session.libsession.database.CallDataProvider
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
|
||||||
import org.thoughtcrime.securesms.webrtc.CallManager
|
import org.thoughtcrime.securesms.webrtc.CallManager
|
||||||
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
|
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@ -25,7 +21,7 @@ object CallModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) =
|
fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: StorageProtocol) =
|
||||||
CallManager(context, audioManagerCompat, storage)
|
CallManager(context, audioManagerCompat, storage)
|
||||||
|
|
||||||
}
|
}
|
@ -2,16 +2,27 @@ package org.thoughtcrime.securesms.dependencies
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Trace
|
import android.os.Trace
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import network.loki.messenger.libsession_util.Config
|
||||||
import network.loki.messenger.libsession_util.ConfigBase
|
import network.loki.messenger.libsession_util.ConfigBase
|
||||||
import network.loki.messenger.libsession_util.Contacts
|
import network.loki.messenger.libsession_util.Contacts
|
||||||
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
||||||
|
import network.loki.messenger.libsession_util.GroupInfoConfig
|
||||||
|
import network.loki.messenger.libsession_util.GroupKeysConfig
|
||||||
|
import network.loki.messenger.libsession_util.GroupMembersConfig
|
||||||
import network.loki.messenger.libsession_util.UserGroupsConfig
|
import network.loki.messenger.libsession_util.UserGroupsConfig
|
||||||
import network.loki.messenger.libsession_util.UserProfile
|
import network.loki.messenger.libsession_util.UserProfile
|
||||||
|
import network.loki.messenger.libsession_util.util.Sodium
|
||||||
|
import org.session.libsession.messaging.messages.Destination
|
||||||
|
import org.session.libsession.snode.SnodeAPI
|
||||||
import org.session.libsession.utilities.ConfigFactoryProtocol
|
import org.session.libsession.utilities.ConfigFactoryProtocol
|
||||||
import org.session.libsession.utilities.ConfigFactoryUpdateListener
|
import org.session.libsession.utilities.ConfigFactoryUpdateListener
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
|
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
|
||||||
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.thoughtcrime.securesms.database.ConfigDatabase
|
import org.thoughtcrime.securesms.database.ConfigDatabase
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
|
||||||
import org.thoughtcrime.securesms.groups.GroupManager
|
import org.thoughtcrime.securesms.groups.GroupManager
|
||||||
@ -20,6 +31,7 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
|||||||
class ConfigFactory(
|
class ConfigFactory(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val configDatabase: ConfigDatabase,
|
private val configDatabase: ConfigDatabase,
|
||||||
|
/** <ed25519 secret key,33 byte prefixed public key (hex encoded)> */
|
||||||
private val maybeGetUserInfo: () -> Pair<ByteArray, String>?
|
private val maybeGetUserInfo: () -> Pair<ByteArray, String>?
|
||||||
) :
|
) :
|
||||||
ConfigFactoryProtocol {
|
ConfigFactoryProtocol {
|
||||||
@ -28,10 +40,10 @@ class ConfigFactory(
|
|||||||
// config change, any message which would normally result in a config change which was sent
|
// config change, any message which would normally result in a config change which was sent
|
||||||
// before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have
|
// before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have
|
||||||
// it's changes applied (control text will still be added though)
|
// it's changes applied (control text will still be added though)
|
||||||
val configChangeBufferPeriod: Long = (2 * 60 * 1000)
|
const val configChangeBufferPeriod: Long = (2 * 60 * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun keyPairChanged() { // this should only happen restoring or clearing data
|
fun keyPairChanged() { // this should only happen restoring or clearing datac
|
||||||
_userConfig?.free()
|
_userConfig?.free()
|
||||||
_contacts?.free()
|
_contacts?.free()
|
||||||
_convoVolatileConfig?.free()
|
_convoVolatileConfig?.free()
|
||||||
@ -52,6 +64,13 @@ class ConfigFactory(
|
|||||||
private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
|
private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
|
||||||
|
|
||||||
private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf()
|
private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf()
|
||||||
|
|
||||||
|
private val _configUpdateNotifications = MutableSharedFlow<Unit>(
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
override val configUpdateNotifications get() = _configUpdateNotifications
|
||||||
|
|
||||||
fun registerListener(listener: ConfigFactoryUpdateListener) {
|
fun registerListener(listener: ConfigFactoryUpdateListener) {
|
||||||
listeners += listener
|
listeners += listener
|
||||||
}
|
}
|
||||||
@ -146,6 +165,101 @@ class ConfigFactory(
|
|||||||
_userGroups
|
_userGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getGroupInfo(groupSessionId: AccountId) = userGroups?.getClosedGroup(groupSessionId.hexString)
|
||||||
|
|
||||||
|
override fun getGroupInfoConfig(groupSessionId: AccountId): GroupInfoConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
|
||||||
|
// get any potential initial dumps
|
||||||
|
val dump = configDatabase.retrieveConfigAndHashes(
|
||||||
|
ConfigDatabase.INFO_VARIANT,
|
||||||
|
groupSessionId.hexString
|
||||||
|
) ?: byteArrayOf()
|
||||||
|
|
||||||
|
GroupInfoConfig.newInstance(groupSessionId.pubKeyBytes, groupInfo.adminKey, dump)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getGroupKeysConfig(groupSessionId: AccountId,
|
||||||
|
info: GroupInfoConfig?,
|
||||||
|
members: GroupMembersConfig?,
|
||||||
|
free: Boolean): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
|
||||||
|
// Get the user info or return early
|
||||||
|
val (userSk, _) = maybeGetUserInfo() ?: return@let null
|
||||||
|
|
||||||
|
// Get the group info or return early
|
||||||
|
val usedInfo = info ?: getGroupInfoConfig(groupSessionId) ?: return@let null
|
||||||
|
|
||||||
|
// Get the group members or return early
|
||||||
|
val usedMembers = members ?: getGroupMemberConfig(groupSessionId) ?: return@let null
|
||||||
|
|
||||||
|
// Get the dump or empty
|
||||||
|
val dump = configDatabase.retrieveConfigAndHashes(
|
||||||
|
ConfigDatabase.KEYS_VARIANT,
|
||||||
|
groupSessionId.hexString
|
||||||
|
) ?: byteArrayOf()
|
||||||
|
|
||||||
|
// Put it all together
|
||||||
|
val keys = GroupKeysConfig.newInstance(
|
||||||
|
userSk,
|
||||||
|
groupSessionId.pubKeyBytes,
|
||||||
|
groupInfo.adminKey,
|
||||||
|
dump,
|
||||||
|
usedInfo,
|
||||||
|
usedMembers
|
||||||
|
)
|
||||||
|
if (free) {
|
||||||
|
info?.free()
|
||||||
|
members?.free()
|
||||||
|
}
|
||||||
|
if (usedInfo !== info) usedInfo.free()
|
||||||
|
if (usedMembers !== members) usedMembers.free()
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getGroupMemberConfig(groupSessionId: AccountId): GroupMembersConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
|
||||||
|
// Get initial dump if we have one
|
||||||
|
val dump = configDatabase.retrieveConfigAndHashes(
|
||||||
|
ConfigDatabase.MEMBER_VARIANT,
|
||||||
|
groupSessionId.hexString
|
||||||
|
) ?: byteArrayOf()
|
||||||
|
|
||||||
|
GroupMembersConfig.newInstance(
|
||||||
|
groupSessionId.pubKeyBytes,
|
||||||
|
groupInfo.adminKey,
|
||||||
|
dump
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun constructGroupKeysConfig(
|
||||||
|
groupSessionId: AccountId,
|
||||||
|
info: GroupInfoConfig,
|
||||||
|
members: GroupMembersConfig
|
||||||
|
): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
|
||||||
|
val (userSk, _) = maybeGetUserInfo() ?: return null
|
||||||
|
GroupKeysConfig.newInstance(
|
||||||
|
userSk,
|
||||||
|
groupSessionId.pubKeyBytes,
|
||||||
|
groupInfo.adminKey,
|
||||||
|
info = info,
|
||||||
|
members = members
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun userSessionId(): AccountId? {
|
||||||
|
return maybeGetUserInfo()?.second?.let(::AccountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun maybeDecryptForUser(encoded: ByteArray, domain: String, closedGroupSessionId: AccountId): ByteArray? {
|
||||||
|
val secret = maybeGetUserInfo()?.first ?: run {
|
||||||
|
Log.e("ConfigFactory", "No user ed25519 secret key decrypting a message for us")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Sodium.decryptForMultipleSimple(
|
||||||
|
encoded = encoded,
|
||||||
|
ed25519SecretKey = secret,
|
||||||
|
domain = domain,
|
||||||
|
senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun getUserConfigs(): List<ConfigBase> =
|
override fun getUserConfigs(): List<ConfigBase> =
|
||||||
listOfNotNull(user, contacts, convoVolatile, userGroups)
|
listOfNotNull(user, contacts, convoVolatile, userGroups)
|
||||||
|
|
||||||
@ -153,13 +267,23 @@ class ConfigFactory(
|
|||||||
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
|
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
|
||||||
val dumped = user?.dump() ?: return
|
val dumped = user?.dump() ?: return
|
||||||
val (_, publicKey) = maybeGetUserInfo() ?: return
|
val (_, publicKey) = maybeGetUserInfo() ?: return
|
||||||
configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp)
|
configDatabase.storeConfig(
|
||||||
|
SharedConfigMessage.Kind.USER_PROFILE.name,
|
||||||
|
publicKey,
|
||||||
|
dumped,
|
||||||
|
timestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
|
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
|
||||||
val dumped = contacts?.dump() ?: return
|
val dumped = contacts?.dump() ?: return
|
||||||
val (_, publicKey) = maybeGetUserInfo() ?: return
|
val (_, publicKey) = maybeGetUserInfo() ?: return
|
||||||
configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp)
|
configDatabase.storeConfig(
|
||||||
|
SharedConfigMessage.Kind.CONTACTS.name,
|
||||||
|
publicKey,
|
||||||
|
dumped,
|
||||||
|
timestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
|
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
|
||||||
@ -176,21 +300,52 @@ class ConfigFactory(
|
|||||||
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
|
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
|
||||||
val dumped = userGroups?.dump() ?: return
|
val dumped = userGroups?.dump() ?: return
|
||||||
val (_, publicKey) = maybeGetUserInfo() ?: return
|
val (_, publicKey) = maybeGetUserInfo() ?: return
|
||||||
configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp)
|
configDatabase.storeConfig(
|
||||||
|
SharedConfigMessage.Kind.GROUPS.name,
|
||||||
|
publicKey,
|
||||||
|
dumped,
|
||||||
|
timestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
|
fun persistGroupConfigDump(forConfigObject: ConfigBase, groupSessionId: AccountId, timestamp: Long) = synchronized(userGroupsLock) {
|
||||||
|
val dumped = forConfigObject.dump()
|
||||||
|
val variant = when (forConfigObject) {
|
||||||
|
is GroupMembersConfig -> ConfigDatabase.MEMBER_VARIANT
|
||||||
|
is GroupInfoConfig -> ConfigDatabase.INFO_VARIANT
|
||||||
|
else -> throw Exception("Shouldn't be called")
|
||||||
|
}
|
||||||
|
configDatabase.storeConfig(
|
||||||
|
variant,
|
||||||
|
groupSessionId.hexString,
|
||||||
|
dumped,
|
||||||
|
timestamp
|
||||||
|
)
|
||||||
|
_configUpdateNotifications.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String?) {
|
||||||
try {
|
try {
|
||||||
|
if (forConfigObject is ConfigBase && !forConfigObject.needsDump() || forConfigObject is GroupKeysConfig && !forConfigObject.needsDump()) {
|
||||||
|
Log.d("ConfigFactory", "Don't need to persist ${forConfigObject.javaClass} for $forPublicKey pubkey")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
listeners.forEach { listener ->
|
listeners.forEach { listener ->
|
||||||
listener.notifyUpdates(forConfigObject, timestamp)
|
listener.notifyUpdates(forConfigObject, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (forConfigObject) {
|
when (forConfigObject) {
|
||||||
is UserProfile -> persistUserConfigDump(timestamp)
|
is UserProfile -> persistUserConfigDump(timestamp)
|
||||||
is Contacts -> persistContactsConfigDump(timestamp)
|
is Contacts -> persistContactsConfigDump(timestamp)
|
||||||
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
|
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
|
||||||
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
|
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
|
||||||
|
is GroupMembersConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp)
|
||||||
|
is GroupInfoConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp)
|
||||||
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
|
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_configUpdateNotifications.tryEmit(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
|
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
|
||||||
}
|
}
|
||||||
@ -207,23 +362,25 @@ class ConfigFactory(
|
|||||||
if (openGroupId != null) {
|
if (openGroupId != null) {
|
||||||
val userGroups = userGroups ?: return false
|
val userGroups = userGroups ?: return false
|
||||||
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
|
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
|
||||||
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
|
val openGroup =
|
||||||
|
get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
|
||||||
|
|
||||||
// Not handling the `hidden` behaviour for communities so just indicate the existence
|
// Not handling the `hidden` behaviour for communities so just indicate the existence
|
||||||
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
|
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
|
||||||
}
|
} else if (groupPublicKey != null) {
|
||||||
else if (groupPublicKey != null) {
|
|
||||||
val userGroups = userGroups ?: return false
|
val userGroups = userGroups ?: return false
|
||||||
|
|
||||||
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
|
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
|
||||||
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null)
|
return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
|
||||||
|
userGroups.getClosedGroup(groupPublicKey) != null
|
||||||
|
} else {
|
||||||
|
userGroups.getLegacyGroupInfo(groupPublicKey) != null
|
||||||
}
|
}
|
||||||
else if (publicKey == userPublicKey) {
|
} else if (publicKey == userPublicKey) {
|
||||||
val user = user ?: return false
|
val user = user ?: return false
|
||||||
|
|
||||||
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
|
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
|
||||||
}
|
} else if (publicKey != null) {
|
||||||
else if (publicKey != null) {
|
|
||||||
val contacts = contacts ?: return false
|
val contacts = contacts ?: return false
|
||||||
val targetContact = contacts.get(publicKey) ?: return false
|
val targetContact = contacts.get(publicKey) ?: return false
|
||||||
|
|
||||||
@ -233,10 +390,44 @@ class ConfigFactory(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
|
override fun canPerformChange(
|
||||||
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
|
variant: String,
|
||||||
|
publicKey: String,
|
||||||
|
changeTimestampMs: Long
|
||||||
|
): Boolean {
|
||||||
|
val lastUpdateTimestampMs =
|
||||||
|
configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
|
||||||
|
|
||||||
// Ensure the change occurred after the last config message was handled (minus the buffer period)
|
// Ensure the change occurred after the last config message was handled (minus the buffer period)
|
||||||
return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod))
|
return (changeTimestampMs >= (lastUpdateTimestampMs - configChangeBufferPeriod))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveGroupConfigs(
|
||||||
|
groupKeys: GroupKeysConfig,
|
||||||
|
groupInfo: GroupInfoConfig,
|
||||||
|
groupMembers: GroupMembersConfig
|
||||||
|
) {
|
||||||
|
val pubKey = groupInfo.id().hexString
|
||||||
|
val timestamp = SnodeAPI.nowWithOffset
|
||||||
|
|
||||||
|
// this would be nicer with a .any iteration or something but the base types don't line up
|
||||||
|
val anyNeedDump = groupKeys.needsDump() || groupInfo.needsDump() || groupMembers.needsDump()
|
||||||
|
if (!anyNeedDump) return Log.d("ConfigFactory", "Group config doesn't need dump, skipping")
|
||||||
|
else Log.d("ConfigFactory", "Group config needs dump, storing and notifying")
|
||||||
|
|
||||||
|
configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp)
|
||||||
|
_configUpdateNotifications.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeGroup(closedGroupId: AccountId) {
|
||||||
|
val groups = userGroups ?: return
|
||||||
|
groups.eraseClosedGroup(closedGroupId.hexString)
|
||||||
|
persist(groups, SnodeAPI.nowWithOffset)
|
||||||
|
configDatabase.deleteGroupConfigs(closedGroupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scheduleUpdate(destination: Destination) {
|
||||||
|
// there's probably a better way to do this
|
||||||
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.session.libsession.database.MessageDataProvider
|
import org.session.libsession.database.MessageDataProvider
|
||||||
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.thoughtcrime.securesms.ApplicationContext
|
import org.thoughtcrime.securesms.ApplicationContext
|
||||||
import org.thoughtcrime.securesms.database.*
|
import org.thoughtcrime.securesms.database.*
|
||||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||||
|
@ -141,8 +141,13 @@ object DatabaseModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
|
fun provideStorage(@ApplicationContext context: Context,
|
||||||
val storage = Storage(context,openHelper, configFactory)
|
openHelper: SQLCipherOpenHelper,
|
||||||
|
configFactory: ConfigFactory,
|
||||||
|
threadDatabase: ThreadDatabase,
|
||||||
|
pollerFactory: PollerFactory,
|
||||||
|
): Storage {
|
||||||
|
val storage = Storage(context, openHelper, configFactory, pollerFactory)
|
||||||
threadDatabase.setUpdateListener(storage)
|
threadDatabase.setUpdateListener(storage)
|
||||||
return storage
|
return storage
|
||||||
}
|
}
|
||||||
|
@ -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.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
import org.session.libsession.utilities.ConfigFactoryUpdateListener
|
import org.session.libsession.utilities.ConfigFactoryUpdateListener
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
||||||
import org.thoughtcrime.securesms.database.ConfigDatabase
|
import org.thoughtcrime.securesms.database.ConfigDatabase
|
||||||
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object SessionUtilModule {
|
object SessionUtilModule {
|
||||||
|
|
||||||
|
const val POLLER_SCOPE = "poller_coroutine_scope"
|
||||||
|
|
||||||
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
|
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
|
||||||
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
|
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
|
||||||
return edKey.secretKey.asBytes
|
return edKey.secretKey.asBytes
|
||||||
@ -33,4 +41,19 @@ object SessionUtilModule {
|
|||||||
registerListener(context as ConfigFactoryUpdateListener)
|
registerListener(context as ConfigFactoryUpdateListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Named(POLLER_SCOPE)
|
||||||
|
fun providePollerScope(@ApplicationContext applicationContext: Context): CoroutineScope = GlobalScope
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Provides
|
||||||
|
@Named(POLLER_SCOPE)
|
||||||
|
fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope,
|
||||||
|
@Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher,
|
||||||
|
configFactory: ConfigFactory) = PollerFactory(coroutineScope, dispatcher, configFactory)
|
||||||
|
|
||||||
}
|
}
|
@ -4,7 +4,7 @@ import android.content.Context
|
|||||||
import network.loki.messenger.libsession_util.ConfigBase
|
import network.loki.messenger.libsession_util.ConfigBase
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
|
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
|
||||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
|
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
import org.session.libsession.utilities.GroupRecord
|
import org.session.libsession.utilities.GroupRecord
|
||||||
import org.session.libsession.utilities.GroupUtil
|
import org.session.libsession.utilities.GroupUtil
|
||||||
@ -25,7 +25,7 @@ object ClosedGroupManager {
|
|||||||
// Notify the PN server
|
// Notify the PN server
|
||||||
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
|
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
|
||||||
// Stop polling
|
// Stop polling
|
||||||
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
|
LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
|
||||||
storage.cancelPendingMessageSendJobs(threadId)
|
storage.cancelPendingMessageSendJobs(threadId)
|
||||||
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
|
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
|
||||||
if (delete) {
|
if (delete) {
|
||||||
@ -33,16 +33,9 @@ object ClosedGroupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean {
|
|
||||||
val groups = userGroups ?: return false
|
|
||||||
if (!group.isClosedGroup) return false
|
|
||||||
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
|
|
||||||
return groups.eraseLegacyGroup(groupPublicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ConfigFactory.updateLegacyGroup(group: GroupRecord) {
|
fun ConfigFactory.updateLegacyGroup(group: GroupRecord) {
|
||||||
val groups = userGroups ?: return
|
val groups = userGroups ?: return
|
||||||
if (!group.isClosedGroup) return
|
if (!group.isLegacyClosedGroup) return
|
||||||
val storage = MessagingModuleConfiguration.shared.storage
|
val storage = MessagingModuleConfiguration.shared.storage
|
||||||
val threadId = storage.getThreadId(group.encodedId) ?: return
|
val threadId = storage.getThreadId(group.encodedId) ?: return
|
||||||
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
|
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
|
||||||
|
@ -1,127 +1,44 @@
|
|||||||
package org.thoughtcrime.securesms.groups
|
package org.thoughtcrime.securesms.groups
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import network.loki.messenger.R
|
import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate
|
||||||
import network.loki.messenger.databinding.FragmentCreateGroupBinding
|
|
||||||
import nl.komponents.kovenant.ui.failUi
|
|
||||||
import nl.komponents.kovenant.ui.successUi
|
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
|
||||||
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
|
|
||||||
import org.session.libsession.utilities.Address
|
|
||||||
import org.session.libsession.utilities.Device
|
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
|
||||||
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
|
|
||||||
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
|
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen
|
||||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import org.thoughtcrime.securesms.util.fadeIn
|
|
||||||
import org.thoughtcrime.securesms.util.fadeOut
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class CreateGroupFragment : Fragment() {
|
class CreateGroupFragment : Fragment() {
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var device: Device
|
|
||||||
|
|
||||||
private lateinit var binding: FragmentCreateGroupBinding
|
|
||||||
private val viewModel: CreateGroupViewModel by viewModels()
|
|
||||||
|
|
||||||
lateinit var delegate: StartConversationDelegate
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentCreateGroupBinding.inflate(inflater)
|
return ComposeView(requireContext()).apply {
|
||||||
return binding.root
|
val delegate = (parentFragment as? StartConversationDelegate)
|
||||||
}
|
?: (activity as? StartConversationDelegate)
|
||||||
|
?: NullStartConversationDelegate
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
setContent {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
SessionMaterialTheme {
|
||||||
val adapter = SelectContactsAdapter(requireContext(), Glide.with(requireContext()))
|
CreateGroupScreen(
|
||||||
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() }
|
onNavigateToConversationScreen = { threadID ->
|
||||||
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
|
startActivity(
|
||||||
binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks {
|
Intent(requireContext(), ConversationActivityV2::class.java)
|
||||||
override fun onQueryChanged(query: String) {
|
.putExtra(ConversationActivityV2.THREAD_ID, threadID)
|
||||||
adapter.members = viewModel.filter(query).map { it.address.serialize() }
|
)
|
||||||
}
|
},
|
||||||
}
|
onBack = delegate::onDialogBackPressed,
|
||||||
binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
|
onClose = delegate::onDialogClosePressed
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
|
|
||||||
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
|
|
||||||
setDrawable(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.recyclerView.addItemDecoration(divider)
|
|
||||||
var isLoading = false
|
|
||||||
binding.createClosedGroupButton.setOnClickListener {
|
|
||||||
if (isLoading) return@setOnClickListener
|
|
||||||
val name = binding.nameEditText.text.trim()
|
|
||||||
if (name.isEmpty()) {
|
|
||||||
return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterPlease, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit the group name length if it exceeds the limit
|
|
||||||
if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) {
|
|
||||||
return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterShorter, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
val selectedMembers = adapter.selectedMembers
|
|
||||||
if (selectedMembers.isEmpty()) {
|
|
||||||
return@setOnClickListener Toast.makeText(context, R.string.groupCreateErrorNoMembers, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later
|
|
||||||
return@setOnClickListener Toast.makeText(context, R.string.groupAddMemberMaximum, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!!
|
|
||||||
isLoading = true
|
|
||||||
binding.loaderContainer.fadeIn()
|
|
||||||
MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
|
|
||||||
binding.loaderContainer.fadeOut()
|
|
||||||
isLoading = false
|
|
||||||
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false))
|
|
||||||
openConversationActivity(
|
|
||||||
requireContext(),
|
|
||||||
threadID,
|
|
||||||
Recipient.from(requireContext(), Address.fromSerialized(groupID), false)
|
|
||||||
)
|
)
|
||||||
delegate.onDialogClosePressed()
|
|
||||||
}.failUi {
|
|
||||||
binding.loaderContainer.fadeOut()
|
|
||||||
isLoading = false
|
|
||||||
Toast.makeText(context, it.message, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.mainContentGroup.isVisible = !viewModel.recipients.value.isNullOrEmpty()
|
}
|
||||||
binding.emptyStateGroup.isVisible = viewModel.recipients.value.isNullOrEmpty()
|
|
||||||
viewModel.recipients.observe(viewLifecycleOwner) { recipients ->
|
|
||||||
adapter.members = recipients.map { it.address.serialize() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) {
|
|
||||||
val intent = Intent(context, ConversationActivityV2::class.java)
|
|
||||||
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
|
|
||||||
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address)
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,46 +1,93 @@
|
|||||||
package org.thoughtcrime.securesms.groups
|
package org.thoughtcrime.securesms.groups
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CreateGroupViewModel @Inject constructor(
|
class CreateGroupViewModel @Inject constructor(
|
||||||
private val threadDb: ThreadDatabase,
|
configFactory: ConfigFactory,
|
||||||
private val textSecurePreferences: TextSecurePreferences
|
private val storage: StorageProtocol,
|
||||||
): ViewModel() {
|
): ViewModel() {
|
||||||
|
// Child view model to handle contact selection logic
|
||||||
|
val selectContactsViewModel = SelectContactsViewModel(
|
||||||
|
storage = storage,
|
||||||
|
configFactory = configFactory,
|
||||||
|
excludingAccountIDs = emptySet(),
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
|
||||||
private val _recipients = MutableLiveData<List<Recipient>>()
|
// Input: group name
|
||||||
val recipients: LiveData<List<Recipient>> = _recipients
|
private val mutableGroupName = MutableStateFlow("")
|
||||||
|
private val mutableGroupNameError = MutableStateFlow("")
|
||||||
|
|
||||||
init {
|
// Output: group name
|
||||||
|
val groupName: StateFlow<String> get() = mutableGroupName
|
||||||
|
val groupNameError: StateFlow<String> get() = mutableGroupNameError
|
||||||
|
|
||||||
|
// Output: loading state
|
||||||
|
private val mutableIsLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> get() = mutableIsLoading
|
||||||
|
|
||||||
|
// Events
|
||||||
|
private val mutableEvents = MutableSharedFlow<CreateGroupEvent>()
|
||||||
|
val events: SharedFlow<CreateGroupEvent> get() = mutableEvents
|
||||||
|
|
||||||
|
fun onCreateClicked() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
threadDb.approvedConversationList.use { openCursor ->
|
val groupName = groupName.value.trim()
|
||||||
val reader = threadDb.readerFor(openCursor)
|
if (groupName.isBlank()) {
|
||||||
val recipients = mutableListOf<Recipient>()
|
mutableGroupNameError.value = "Group name cannot be empty"
|
||||||
while (true) {
|
return@launch
|
||||||
recipients += reader.next?.recipient ?: break
|
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_recipients.value = recipients
|
val selected = selectContactsViewModel.currentSelected
|
||||||
.filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() }
|
if (selected.isEmpty()) {
|
||||||
|
mutableEvents.emit(CreateGroupEvent.Error("Please select at least one contact"))
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutableIsLoading.value = true
|
||||||
|
|
||||||
|
val recipient = withContext(Dispatchers.Default) {
|
||||||
|
storage.createNewGroup(groupName, "", selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipient.isPresent) {
|
||||||
|
val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.get().address) }
|
||||||
|
mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId))
|
||||||
|
} else {
|
||||||
|
mutableEvents.emit(CreateGroupEvent.Error("Failed to create group"))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableIsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun filter(query: String): List<Recipient> {
|
fun onGroupNameChanged(name: String) {
|
||||||
return _recipients.value?.filter {
|
mutableGroupName.value = if (name.length > MAX_GROUP_NAME_LENGTH) {
|
||||||
it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true
|
name.substring(0, MAX_GROUP_NAME_LENGTH)
|
||||||
} ?: emptyList()
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableGroupNameError.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface CreateGroupEvent {
|
||||||
|
data class NavigateToConversation(val threadID: Long): CreateGroupEvent
|
||||||
|
|
||||||
|
data class Error(val message: String): CreateGroupEvent
|
||||||
|
}
|
@ -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.dependencies.DatabaseComponent
|
||||||
import org.thoughtcrime.securesms.util.AsyncLoader
|
import org.thoughtcrime.securesms.util.AsyncLoader
|
||||||
|
|
||||||
class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditClosedGroupActivity.GroupMembers>(context) {
|
class EditLegacyClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditLegacyGroupActivity.GroupMembers>(context) {
|
||||||
|
|
||||||
override fun loadInBackground(): EditClosedGroupActivity.GroupMembers {
|
override fun loadInBackground(): EditLegacyGroupActivity.GroupMembers {
|
||||||
val groupDatabase = DatabaseComponent.get(context).groupDatabase()
|
val groupDatabase = DatabaseComponent.get(context).groupDatabase()
|
||||||
val members = groupDatabase.getGroupMembers(groupID, true)
|
val members = groupDatabase.getGroupMembers(groupID, true)
|
||||||
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
|
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
|
||||||
return EditClosedGroupActivity.GroupMembers(
|
return EditLegacyGroupActivity.GroupMembers(
|
||||||
members.map {
|
members.map {
|
||||||
it.address.toString()
|
it.address.toString()
|
||||||
},
|
},
|
@ -18,15 +18,12 @@ import androidx.loader.app.LoaderManager
|
|||||||
import androidx.loader.content.Loader
|
import androidx.loader.content.Loader
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
import com.squareup.phrase.Phrase
|
import com.squareup.phrase.Phrase
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import nl.komponents.kovenant.Promise
|
|
||||||
import nl.komponents.kovenant.task
|
|
||||||
import nl.komponents.kovenant.ui.failUi
|
|
||||||
import nl.komponents.kovenant.ui.successUi
|
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||||
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
|
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
@ -43,12 +40,11 @@ import org.thoughtcrime.securesms.database.Storage
|
|||||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
|
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import org.thoughtcrime.securesms.util.fadeIn
|
import org.thoughtcrime.securesms.util.fadeIn
|
||||||
import org.thoughtcrime.securesms.util.fadeOut
|
import org.thoughtcrime.securesms.util.fadeOut
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var groupConfigFactory: ConfigFactory
|
lateinit var groupConfigFactory: ConfigFactory
|
||||||
@ -80,9 +76,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
|
|
||||||
private val memberListAdapter by lazy {
|
private val memberListAdapter by lazy {
|
||||||
if (isSelfAdmin)
|
if (isSelfAdmin)
|
||||||
EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick)
|
EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick)
|
||||||
else
|
else
|
||||||
EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin)
|
EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var mainContentContainer: LinearLayout
|
private lateinit var mainContentContainer: LinearLayout
|
||||||
@ -129,7 +125,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
|
|
||||||
findViewById<RecyclerView>(R.id.rvUserList).apply {
|
findViewById<RecyclerView>(R.id.rvUserList).apply {
|
||||||
adapter = memberListAdapter
|
adapter = memberListAdapter
|
||||||
layoutManager = LinearLayoutManager(this@EditClosedGroupActivity)
|
layoutManager = LinearLayoutManager(this@EditLegacyGroupActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
lblGroupNameDisplay.text = originalName
|
lblGroupNameDisplay.text = originalName
|
||||||
@ -162,13 +158,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<GroupMembers> {
|
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<GroupMembers> {
|
||||||
|
|
||||||
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> {
|
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> {
|
||||||
return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID)
|
return EditLegacyClosedGroupLoader(this@EditLegacyGroupActivity, groupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoadFinished(loader: Loader<GroupMembers>, groupMembers: GroupMembers) {
|
override fun onLoadFinished(loader: Loader<GroupMembers>, groupMembers: GroupMembers) {
|
||||||
// We no longer need any subsequent loading events
|
// We no longer need any subsequent loading events
|
||||||
// (they will occur on every activity resume).
|
// (they will occur on every activity resume).
|
||||||
LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID)
|
LoaderManager.getInstance(this@EditLegacyGroupActivity).destroyLoader(loaderID)
|
||||||
|
|
||||||
members.clear()
|
members.clear()
|
||||||
members.addAll(groupMembers.members.toHashSet())
|
members.addAll(groupMembers.members.toHashSet())
|
||||||
@ -192,7 +188,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Updating
|
// region Updating
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
when (requestCode) {
|
when (requestCode) {
|
||||||
@ -252,7 +247,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onAddMembersClick() {
|
private fun onAddMembersClick() {
|
||||||
val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java)
|
val intent = Intent(this@EditLegacyGroupActivity, SelectContactsActivity::class.java)
|
||||||
intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
|
intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
|
||||||
intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
|
intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
|
||||||
startActivityForResult(intent, addUsersRequestCode)
|
startActivityForResult(intent, addUsersRequestCode)
|
||||||
@ -320,10 +315,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
if (isClosedGroup) {
|
if (isClosedGroup) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
loaderContainer.fadeIn()
|
loaderContainer.fadeIn()
|
||||||
val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
|
try {
|
||||||
|
if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
|
||||||
MessageSender.explicitLeave(groupPublicKey!!, false)
|
MessageSender.explicitLeave(groupPublicKey!!, false)
|
||||||
} else {
|
} else {
|
||||||
task {
|
|
||||||
if (hasNameChanged) {
|
if (hasNameChanged) {
|
||||||
MessageSender.explicitNameChange(groupPublicKey!!, name)
|
MessageSender.explicitNameChange(groupPublicKey!!, name)
|
||||||
}
|
}
|
||||||
@ -334,15 +329,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
|
if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
promise.successUi {
|
|
||||||
loaderContainer.fadeOut()
|
loaderContainer.fadeOut()
|
||||||
isLoading = false
|
isLoading = false
|
||||||
updateGroupConfig()
|
updateGroupConfig()
|
||||||
finish()
|
finish()
|
||||||
}.failUi { exception ->
|
} catch (exception: Exception) {
|
||||||
val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
|
val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
|
||||||
Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show()
|
Toast.makeText(this@EditLegacyGroupActivity, message, Toast.LENGTH_LONG).show()
|
||||||
loaderContainer.fadeOut()
|
loaderContainer.fadeOut()
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
@ -350,8 +343,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateGroupConfig() {
|
private fun updateGroupConfig() {
|
||||||
val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
|
|
||||||
?: return Log.w("Loki", "No recipient settings when trying to update group config")
|
|
||||||
val latestGroup = storage.getGroup(groupID)
|
val latestGroup = storage.getGroup(groupID)
|
||||||
?: return Log.w("Loki", "No group record when trying to update group config")
|
?: return Log.w("Loki", "No group record when trying to update group config")
|
||||||
groupConfigFactory.updateLegacyGroup(latestGroup)
|
groupConfigFactory.updateLegacyGroup(latestGroup)
|
@ -9,12 +9,12 @@ import com.bumptech.glide.RequestManager
|
|||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
|
|
||||||
class EditClosedGroupMembersAdapter(
|
class EditLegacyGroupMembersAdapter(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val glide: RequestManager,
|
private val glide: RequestManager,
|
||||||
private val admin: Boolean,
|
private val admin: Boolean,
|
||||||
private val memberClickListener: ((String) -> Unit)? = null
|
private val memberClickListener: ((String) -> Unit)? = null
|
||||||
) : RecyclerView.Adapter<EditClosedGroupMembersAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<EditLegacyGroupMembersAdapter.ViewHolder>() {
|
||||||
|
|
||||||
private val members = ArrayList<String>()
|
private val members = ArrayList<String>()
|
||||||
private val zombieMembers = ArrayList<String>()
|
private val zombieMembers = ArrayList<String>()
|
@ -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 com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
|
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
|
||||||
|
import org.session.libsession.utilities.GroupRecord
|
||||||
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||||
import org.thoughtcrime.securesms.util.getConversationUnread
|
import org.thoughtcrime.securesms.util.getConversationUnread
|
||||||
@ -21,7 +23,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
|||||||
// is not the best idea. It doesn't survive configuration change.
|
// is not the best idea. It doesn't survive configuration change.
|
||||||
// We should be dealing with IDs and all sorts of serializable data instead
|
// We should be dealing with IDs and all sorts of serializable data instead
|
||||||
// if we want to use dialog fragments properly.
|
// if we want to use dialog fragments properly.
|
||||||
|
lateinit var publicKey: String
|
||||||
lateinit var thread: ThreadRecord
|
lateinit var thread: ThreadRecord
|
||||||
|
var group: GroupRecord? = null
|
||||||
|
|
||||||
@Inject lateinit var configFactory: ConfigFactory
|
@Inject lateinit var configFactory: ConfigFactory
|
||||||
|
|
||||||
@ -51,6 +55,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
|||||||
binding.blockTextView -> onBlockTapped?.invoke()
|
binding.blockTextView -> onBlockTapped?.invoke()
|
||||||
binding.unblockTextView -> onUnblockTapped?.invoke()
|
binding.unblockTextView -> onUnblockTapped?.invoke()
|
||||||
binding.deleteTextView -> onDeleteTapped?.invoke()
|
binding.deleteTextView -> onDeleteTapped?.invoke()
|
||||||
|
binding.leaveTextView -> onDeleteTapped?.invoke()
|
||||||
binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke()
|
binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke()
|
||||||
binding.notificationsTextView -> onNotificationTapped?.invoke()
|
binding.notificationsTextView -> onNotificationTapped?.invoke()
|
||||||
binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false)
|
binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false)
|
||||||
@ -62,6 +67,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
if (!this::thread.isInitialized) { return dismiss() }
|
if (!this::thread.isInitialized) { return dismiss() }
|
||||||
val recipient = thread.recipient
|
val recipient = thread.recipient
|
||||||
|
val isCurrentUserInGroup = group?.members?.map { it.toString() }?.contains(publicKey) ?: false
|
||||||
if (!recipient.isGroupRecipient && !recipient.isLocalNumber) {
|
if (!recipient.isGroupRecipient && !recipient.isLocalNumber) {
|
||||||
binding.detailsTextView.visibility = View.VISIBLE
|
binding.detailsTextView.visibility = View.VISIBLE
|
||||||
binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE
|
binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE
|
||||||
@ -82,7 +88,10 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
|||||||
binding.muteNotificationsTextView.setOnClickListener(this)
|
binding.muteNotificationsTextView.setOnClickListener(this)
|
||||||
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
|
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
|
||||||
binding.notificationsTextView.setOnClickListener(this)
|
binding.notificationsTextView.setOnClickListener(this)
|
||||||
|
binding.deleteTextView.isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup)
|
||||||
binding.deleteTextView.setOnClickListener(this)
|
binding.deleteTextView.setOnClickListener(this)
|
||||||
|
binding.leaveTextView.isVisible = recipient.isGroupRecipient && isCurrentUserInGroup
|
||||||
|
binding.leaveTextView.setOnClickListener(this)
|
||||||
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
|
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
|
||||||
binding.markAllAsReadTextView.setOnClickListener(this)
|
binding.markAllAsReadTextView.setOnClickListener(this)
|
||||||
binding.pinTextView.isVisible = !thread.isPinned
|
binding.pinTextView.isVisible = !thread.isPinned
|
||||||
|
@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
import android.text.TextUtils
|
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@ -16,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewConversationBinding
|
import network.loki.messenger.databinding.ViewConversationBinding
|
||||||
import org.session.libsession.utilities.ThemeUtil
|
import org.session.libsession.utilities.ThemeUtil
|
||||||
|
import org.session.libsession.utilities.getColorFromAttr
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions
|
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
|
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
|
||||||
@ -50,6 +50,16 @@ class ConversationView : LinearLayout {
|
|||||||
|
|
||||||
// region Updating
|
// region Updating
|
||||||
fun bind(thread: ThreadRecord, isTyping: Boolean) {
|
fun bind(thread: ThreadRecord, isTyping: Boolean) {
|
||||||
|
if (thread.isLeavingGroup) {
|
||||||
|
binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
|
||||||
|
binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
|
||||||
|
} else if (thread.isErrorLeavingGroup) {
|
||||||
|
binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||||
|
binding.snippetTextView.setTextColor(context.getColorFromAttr(R.attr.danger))
|
||||||
|
} else {
|
||||||
|
binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||||
|
binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||||
|
}
|
||||||
this.thread = thread
|
this.thread = thread
|
||||||
if (thread.isPinned) {
|
if (thread.isPinned) {
|
||||||
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||||
|
@ -36,6 +36,7 @@ import org.greenrobot.eventbus.Subscribe
|
|||||||
import org.greenrobot.eventbus.ThreadMode
|
import org.greenrobot.eventbus.ThreadMode
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
|
import org.session.libsession.messaging.jobs.LibSessionGroupLeavingJob
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||||
import org.session.libsession.snode.SnodeAPI
|
import org.session.libsession.snode.SnodeAPI
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
@ -43,6 +44,7 @@ import org.session.libsession.utilities.GroupUtil
|
|||||||
import org.session.libsession.utilities.ProfilePictureModifiedEvent
|
import org.session.libsession.utilities.ProfilePictureModifiedEvent
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.session.libsignal.utilities.ThreadUtils
|
import org.session.libsignal.utilities.ThreadUtils
|
||||||
import org.session.libsignal.utilities.toHexString
|
import org.session.libsignal.utilities.toHexString
|
||||||
@ -71,7 +73,6 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
|
|||||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
|
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.RequestManager
|
import com.bumptech.glide.RequestManager
|
||||||
import org.thoughtcrime.securesms.notifications.PushRegistry
|
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions
|
import org.thoughtcrime.securesms.permissions.Permissions
|
||||||
import org.thoughtcrime.securesms.preferences.SettingsActivity
|
import org.thoughtcrime.securesms.preferences.SettingsActivity
|
||||||
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
|
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
|
||||||
@ -116,7 +117,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||||||
@Inject lateinit var groupDatabase: GroupDatabase
|
@Inject lateinit var groupDatabase: GroupDatabase
|
||||||
@Inject lateinit var textSecurePreferences: TextSecurePreferences
|
@Inject lateinit var textSecurePreferences: TextSecurePreferences
|
||||||
@Inject lateinit var configFactory: ConfigFactory
|
@Inject lateinit var configFactory: ConfigFactory
|
||||||
@Inject lateinit var pushRegistry: PushRegistry
|
|
||||||
|
|
||||||
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
|
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
|
||||||
private val homeViewModel by viewModels<HomeViewModel>()
|
private val homeViewModel by viewModels<HomeViewModel>()
|
||||||
@ -140,9 +140,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||||||
putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
|
putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
|
||||||
}
|
}
|
||||||
is GlobalSearchAdapter.Model.Contact -> push<ConversationActivityV2> {
|
is GlobalSearchAdapter.Model.Contact -> push<ConversationActivityV2> {
|
||||||
putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized))
|
putExtra(
|
||||||
|
ConversationActivityV2.ADDRESS,
|
||||||
|
model.contact.accountID.let(Address::fromSerialized)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId
|
|
||||||
|
is GlobalSearchAdapter.Model.LegacyGroupConversation -> model.groupRecord.encodedId
|
||||||
.let { Recipient.from(this, Address.fromSerialized(it), false) }
|
.let { Recipient.from(this, Address.fromSerialized(it), false) }
|
||||||
.let(threadDb::getThreadIdIfExistsFor)
|
.let(threadDb::getThreadIdIfExistsFor)
|
||||||
.takeIf { it >= 0 }
|
.takeIf { it >= 0 }
|
||||||
@ -238,7 +242,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||||||
(applicationContext as ApplicationContext).startPollingIfNeeded()
|
(applicationContext as ApplicationContext).startPollingIfNeeded()
|
||||||
// update things based on TextSecurePrefs (profile info etc)
|
// update things based on TextSecurePrefs (profile info etc)
|
||||||
// Set up remaining components if needed
|
// Set up remaining components if needed
|
||||||
pushRegistry.refresh(false)
|
|
||||||
if (textSecurePreferences.getLocalNumber() != null) {
|
if (textSecurePreferences.getLocalNumber() != null) {
|
||||||
OpenGroupManager.startPolling()
|
OpenGroupManager.startPolling()
|
||||||
JobQueue.shared.resumePendingJobs()
|
JobQueue.shared.resumePendingJobs()
|
||||||
@ -330,7 +333,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||||||
|
|
||||||
private val GlobalSearchResult.contactAndGroupList: List<GlobalSearchAdapter.Model> get() =
|
private val GlobalSearchResult.contactAndGroupList: List<GlobalSearchAdapter.Model> get() =
|
||||||
contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } +
|
contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } +
|
||||||
threads.map(GlobalSearchAdapter.Model::GroupConversation)
|
threads.map(GlobalSearchAdapter.Model::LegacyGroupConversation)
|
||||||
|
|
||||||
private val GlobalSearchResult.messageResults: List<GlobalSearchAdapter.Model> get() {
|
private val GlobalSearchResult.messageResults: List<GlobalSearchAdapter.Model> get() {
|
||||||
val unreadThreadMap = messages
|
val unreadThreadMap = messages
|
||||||
@ -428,7 +431,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||||||
|
|
||||||
override fun onLongConversationClick(thread: ThreadRecord) {
|
override fun onLongConversationClick(thread: ThreadRecord) {
|
||||||
val bottomSheet = ConversationOptionsBottomSheet(this)
|
val bottomSheet = ConversationOptionsBottomSheet(this)
|
||||||
|
bottomSheet.publicKey = publicKey
|
||||||
bottomSheet.thread = thread
|
bottomSheet.thread = thread
|
||||||
|
bottomSheet.group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull()
|
||||||
bottomSheet.onViewDetailsTapped = {
|
bottomSheet.onViewDetailsTapped = {
|
||||||
bottomSheet.dismiss()
|
bottomSheet.dismiss()
|
||||||
val userDetailsBottomSheet = UserDetailsBottomSheet()
|
val userDetailsBottomSheet = UserDetailsBottomSheet()
|
||||||
@ -588,14 +593,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||||||
val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
|
val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
|
||||||
|
|
||||||
// If you are an admin of this group you can delete it
|
// If you are an admin of this group you can delete it
|
||||||
if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) {
|
if (group != null && group.admins.map { it.toString() }
|
||||||
|
.contains(textSecurePreferences.getLocalNumber())) {
|
||||||
title = getString(R.string.groupDelete)
|
title = getString(R.string.groupDelete)
|
||||||
message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription)
|
message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription)
|
||||||
.put(GROUP_NAME_KEY, group.title)
|
.put(GROUP_NAME_KEY, group.title)
|
||||||
.format()
|
.format()
|
||||||
} else {
|
} else {
|
||||||
// Otherwise this is either a community, or it's a group you're not an admin of
|
// Otherwise this is either a community, or it's a group you're not an admin of
|
||||||
title = if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(R.string.groupLeave)
|
title =
|
||||||
|
if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(
|
||||||
|
R.string.groupLeave
|
||||||
|
)
|
||||||
message = Phrase.from(this.applicationContext, R.string.groupLeaveDescription)
|
message = Phrase.from(this.applicationContext, R.string.groupLeaveDescription)
|
||||||
.put(GROUP_NAME_KEY, group.title)
|
.put(GROUP_NAME_KEY, group.title)
|
||||||
.format()
|
.format()
|
||||||
@ -622,25 +631,34 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
|||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
val context = this@HomeActivity
|
val context = this@HomeActivity
|
||||||
// Cancel any outstanding jobs
|
// Cancel any outstanding jobs
|
||||||
DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID)
|
DatabaseComponent.get(context).sessionJobDatabase()
|
||||||
|
.cancelPendingMessageSendJobs(threadID)
|
||||||
// Send a leave group message if this is an active closed group
|
// Send a leave group message if this is an active closed group
|
||||||
if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) {
|
if (recipient.address.isLegacyClosedGroup && DatabaseComponent.get(context)
|
||||||
|
.groupDatabase().isActive(recipient.address.toGroupString())
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
|
GroupUtil.doubleDecodeGroupID(recipient.address.toString())
|
||||||
|
.toHexString()
|
||||||
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
|
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
|
||||||
?.let { MessageSender.explicitLeave(it, false) }
|
?.let { MessageSender.explicitLeave(it, true, deleteThread = true) }
|
||||||
} catch (ioe: IOException) {
|
} catch (ioe: IOException) {
|
||||||
Log.w(TAG, "Got an IOException while sending leave group message")
|
Log.w(TAG, "Got an IOException while sending leave group message", ioe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (recipient.address.isClosedGroupV2) {
|
||||||
|
val groupLeave = LibSessionGroupLeavingJob(AccountId(recipient.address.serialize()), true)
|
||||||
|
JobQueue.shared.add(groupLeave)
|
||||||
|
}
|
||||||
// Delete the conversation
|
// Delete the conversation
|
||||||
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
|
val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase()
|
||||||
|
.getOpenGroupChat(threadID)
|
||||||
if (v2OpenGroup != null) {
|
if (v2OpenGroup != null) {
|
||||||
v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) }
|
OpenGroupManager.delete(
|
||||||
} else {
|
v2OpenGroup.server,
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
v2OpenGroup.room,
|
||||||
threadDb.deleteConversation(threadID)
|
context
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
// Update the badge count
|
// Update the badge count
|
||||||
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
|
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
|
||||||
|
@ -80,7 +80,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
).flowOn(Dispatchers.IO)
|
).flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
|
private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
|
||||||
.map { threadDb.unapprovedConversationCount }
|
.map { threadDb.unapprovedConversationList.use { cursor -> cursor.count } }
|
||||||
|
|
||||||
private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges()
|
private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges()
|
||||||
.map { threadDb.latestUnapprovedConversationTimestamp }
|
.map { threadDb.latestUnapprovedConversationTimestamp }
|
||||||
|
@ -11,7 +11,7 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
|
|||||||
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
|
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
|
||||||
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
|
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
|
||||||
import org.session.libsession.utilities.GroupRecord
|
import org.session.libsession.utilities.GroupRecord
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.thoughtcrime.securesms.search.model.MessageResult
|
import org.thoughtcrime.securesms.search.model.MessageResult
|
||||||
import org.thoughtcrime.securesms.ui.GetString
|
import org.thoughtcrime.securesms.ui.GetString
|
||||||
import java.security.InvalidParameterException
|
import java.security.InvalidParameterException
|
||||||
@ -116,7 +116,7 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
|
|||||||
fun bind(query: String, model: Model) {
|
fun bind(query: String, model: Model) {
|
||||||
binding.searchResultProfilePicture.recycle()
|
binding.searchResultProfilePicture.recycle()
|
||||||
when (model) {
|
when (model) {
|
||||||
is Model.GroupConversation -> bindModel(query, model)
|
is Model.LegacyGroupConversation -> bindModel(query, model)
|
||||||
is Model.Contact -> bindModel(query, model)
|
is Model.Contact -> bindModel(query, model)
|
||||||
is Model.Message -> bindModel(query, model)
|
is Model.Message -> bindModel(query, model)
|
||||||
is Model.SavedMessages -> bindModel(model)
|
is Model.SavedMessages -> bindModel(model)
|
||||||
@ -137,7 +137,8 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
|
|||||||
}
|
}
|
||||||
data class SavedMessages(val currentUserPublicKey: String): Model()
|
data class SavedMessages(val currentUserPublicKey: String): Model()
|
||||||
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model()
|
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model()
|
||||||
data class GroupConversation(val groupRecord: GroupRecord): Model()
|
data class LegacyGroupConversation(val groupRecord: GroupRecord) : Model()
|
||||||
|
data class ClosedGroupConversation(val sessionId: AccountId)
|
||||||
data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : Model()
|
data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : Model()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ import org.session.libsession.utilities.recipients.Recipient
|
|||||||
import org.session.libsession.utilities.truncateIdForDisplay
|
import org.session.libsession.utilities.truncateIdForDisplay
|
||||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
|
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
|
||||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel
|
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel
|
||||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
|
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.LegacyGroupConversation
|
||||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
|
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
|
||||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
|
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
|
||||||
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
|
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
|
||||||
@ -66,7 +66,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
|
|||||||
binding.searchResultSubtitle.isVisible = true
|
binding.searchResultSubtitle.isVisible = true
|
||||||
binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
|
binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
|
||||||
}
|
}
|
||||||
is GroupConversation -> {
|
is LegacyGroupConversation -> {
|
||||||
binding.searchResultTitle.text = getHighlight(
|
binding.searchResultTitle.text = getHighlight(
|
||||||
query,
|
query,
|
||||||
model.groupRecord.title
|
model.groupRecord.title
|
||||||
@ -87,9 +87,9 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
|
|||||||
return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query)
|
return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ContentView.bindModel(query: String?, model: GroupConversation) {
|
fun ContentView.bindModel(query: String?, model: LegacyGroupConversation) {
|
||||||
binding.searchResultProfilePicture.isVisible = true
|
binding.searchResultProfilePicture.isVisible = true
|
||||||
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
|
binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup
|
||||||
binding.searchResultTimestamp.isVisible = false
|
binding.searchResultTimestamp.isVisible = false
|
||||||
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
|
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
|
||||||
binding.searchResultProfilePicture.update(threadRecipient)
|
binding.searchResultProfilePicture.update(threadRecipient)
|
||||||
@ -99,7 +99,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
|
|||||||
val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) }
|
val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) }
|
||||||
|
|
||||||
val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
|
val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
|
||||||
if (model.groupRecord.isClosedGroup) {
|
if (model.groupRecord.isLegacyClosedGroup) {
|
||||||
binding.searchResultSubtitle.text = getHighlight(query, membersString)
|
binding.searchResultSubtitle.text = getHighlight(query, membersString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,13 +127,6 @@ fun ContentView.bindModel(model: SavedMessages) {
|
|||||||
fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
|
fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
|
||||||
searchResultProfilePicture.isVisible = true
|
searchResultProfilePicture.isVisible = true
|
||||||
searchResultTimestamp.isVisible = true
|
searchResultTimestamp.isVisible = true
|
||||||
|
|
||||||
// val hasUnreads = model.unread > 0
|
|
||||||
// unreadCountIndicator.isVisible = hasUnreads
|
|
||||||
// if (hasUnreads) {
|
|
||||||
// unreadCountTextView.text = model.unread.toString()
|
|
||||||
// }
|
|
||||||
|
|
||||||
searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
|
searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
|
||||||
searchResultProfilePicture.update(model.messageResult.conversationRecipient)
|
searchResultProfilePicture.update(model.messageResult.conversationRecipient)
|
||||||
val textSpannable = SpannableStringBuilder()
|
val textSpannable = SpannableStringBuilder()
|
||||||
|
@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
|
|||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ActivityMessageRequestsBinding
|
import network.loki.messenger.databinding.ActivityMessageRequestsBinding
|
||||||
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
|
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
|
||||||
|
import org.session.libsession.utilities.Address
|
||||||
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
@ -80,7 +82,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
|
|||||||
|
|
||||||
override fun onBlockConversationClick(thread: ThreadRecord) {
|
override fun onBlockConversationClick(thread: ThreadRecord) {
|
||||||
fun doBlock() {
|
fun doBlock() {
|
||||||
viewModel.blockMessageRequest(thread)
|
val recipient = thread.invitingAdminId?.let {
|
||||||
|
Recipient.from(this, Address.fromSerialized(it), false)
|
||||||
|
} ?: thread.recipient
|
||||||
|
viewModel.blockMessageRequest(thread, recipient)
|
||||||
LoaderManager.getInstance(this).restartLoader(0, null, this)
|
LoaderManager.getInstance(this).restartLoader(0, null, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,7 +113,11 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
|
|||||||
showSessionDialog {
|
showSessionDialog {
|
||||||
title(R.string.delete)
|
title(R.string.delete)
|
||||||
text(resources.getString(R.string.messageRequestsDelete))
|
text(resources.getString(R.string.messageRequestsDelete))
|
||||||
button(R.string.delete) { doDecline() }
|
if (thread.recipient.isClosedGroupV2Recipient) {
|
||||||
|
dangerButton(R.string.delete, contentDescriptionRes = R.string.delete) { doDecline() }
|
||||||
|
} else {
|
||||||
|
dangerButton(R.string.decline, contentDescriptionRes = R.string.decline) { doDecline() }
|
||||||
|
}
|
||||||
button(R.string.cancel)
|
button(R.string.cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,9 @@ class MessageRequestsAdapter(
|
|||||||
val view = MessageRequestView(context)
|
val view = MessageRequestView(context)
|
||||||
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
|
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
|
||||||
view.setOnLongClickListener {
|
view.setOnLongClickListener {
|
||||||
view.thread?.let { showPopupMenu(view) }
|
view.thread?.let { thread ->
|
||||||
|
showPopupMenu(view, thread.recipient.isGroupRecipient, thread.invitingAdminId)
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
return ViewHolder(view)
|
return ViewHolder(view)
|
||||||
@ -47,10 +49,14 @@ class MessageRequestsAdapter(
|
|||||||
holder?.view?.recycle()
|
holder?.view?.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showPopupMenu(view: MessageRequestView) {
|
private fun showPopupMenu(view: MessageRequestView, groupRecipient: Boolean, invitingAdmin: String?) {
|
||||||
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
|
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
|
||||||
|
// still show the block option if we have an inviting admin for the group
|
||||||
|
if ((groupRecipient && invitingAdmin == null) || view.thread!!.recipient.isOpenGroupInboxRecipient) {
|
||||||
|
popupMenu.menuInflater.inflate(R.menu.menu_group_request, popupMenu.menu)
|
||||||
|
} else {
|
||||||
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
|
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
|
||||||
popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient
|
}
|
||||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||||
if (menuItem.itemId == R.id.menu_delete_message_request) {
|
if (menuItem.itemId == R.id.menu_delete_message_request) {
|
||||||
listener.onDeleteConversationClick(view.thread!!)
|
listener.onDeleteConversationClick(view.thread!!)
|
||||||
|
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@ -13,13 +14,11 @@ class MessageRequestsViewModel @Inject constructor(
|
|||||||
private val repository: ConversationRepository
|
private val repository: ConversationRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
fun blockMessageRequest(thread: ThreadRecord) = viewModelScope.launch {
|
// We assume thread.recipient is a contact or thread.invitingAdmin is not null
|
||||||
val recipient = thread.recipient
|
fun blockMessageRequest(thread: ThreadRecord, blockRecipient: Recipient) = viewModelScope.launch {
|
||||||
if (recipient.isContactRecipient) {
|
repository.setBlocked(thread.threadId, blockRecipient, true)
|
||||||
repository.setBlocked(recipient, true)
|
|
||||||
deleteMessageRequest(thread)
|
deleteMessageRequest(thread)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteMessageRequest(thread: ThreadRecord) = viewModelScope.launch {
|
fun deleteMessageRequest(thread: ThreadRecord) = viewModelScope.launch {
|
||||||
repository.deleteMessageRequest(thread)
|
repository.deleteMessageRequest(thread)
|
||||||
|
@ -7,20 +7,24 @@ import androidx.work.Constraints
|
|||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.Worker
|
import androidx.work.Worker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
import nl.komponents.kovenant.Promise
|
import nl.komponents.kovenant.Promise
|
||||||
import nl.komponents.kovenant.all
|
import nl.komponents.kovenant.all
|
||||||
import nl.komponents.kovenant.functional.bind
|
import nl.komponents.kovenant.functional.bind
|
||||||
|
import org.session.libsession.database.userAuth
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
|
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
|
||||||
import org.session.libsession.messaging.jobs.MessageReceiveParameters
|
import org.session.libsession.messaging.jobs.MessageReceiveParameters
|
||||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
|
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
|
||||||
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
|
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
|
||||||
import org.session.libsession.snode.SnodeAPI
|
import org.session.libsession.snode.SnodeAPI
|
||||||
|
import org.session.libsession.snode.utilities.asyncPromise
|
||||||
|
import org.session.libsession.snode.utilities.await
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.session.libsignal.utilities.recover
|
import org.session.libsignal.utilities.recover
|
||||||
@ -108,20 +112,23 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
|
|||||||
var dmsPromise: Promise<Unit, Exception> = Promise.ofSuccess(Unit)
|
var dmsPromise: Promise<Unit, Exception> = Promise.ofSuccess(Unit)
|
||||||
|
|
||||||
if (requestTargets.contains(Targets.DMS)) {
|
if (requestTargets.contains(Targets.DMS)) {
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth)
|
||||||
dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes ->
|
dmsPromise = SnodeAPI.getMessages(userAuth).bind { envelopes ->
|
||||||
val params = envelopes.map { (envelope, serverHash) ->
|
val params = envelopes.map { (envelope, serverHash) ->
|
||||||
// FIXME: Using a job here seems like a bad idea...
|
// FIXME: Using a job here seems like a bad idea...
|
||||||
MessageReceiveParameters(envelope.toByteArray(), serverHash, null)
|
MessageReceiveParameters(envelope.toByteArray(), serverHash, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GlobalScope.asyncPromise {
|
||||||
BatchMessageReceiveJob(params).executeAsync("background")
|
BatchMessageReceiveJob(params).executeAsync("background")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
promises.add(dmsPromise)
|
promises.add(dmsPromise)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closed groups
|
// Closed groups
|
||||||
if (requestTargets.contains(Targets.CLOSED_GROUPS)) {
|
if (requestTargets.contains(Targets.CLOSED_GROUPS)) {
|
||||||
val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared
|
val closedGroupPoller = LegacyClosedGroupPollerV2() // Intentionally don't use shared
|
||||||
val storage = MessagingModuleConfiguration.shared.storage
|
val storage = MessagingModuleConfiguration.shared.storage
|
||||||
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
|
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
|
||||||
allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) }
|
allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) }
|
||||||
|
@ -43,11 +43,9 @@ import kotlin.concurrent.Volatile
|
|||||||
import me.leolin.shortcutbadger.ShortcutBadger
|
import me.leolin.shortcutbadger.ShortcutBadger
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
|
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
|
||||||
import org.session.libsession.messaging.utilities.AccountId
|
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair
|
import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair
|
||||||
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
||||||
import org.session.libsession.utilities.ServiceUtil
|
import org.session.libsession.utilities.ServiceUtil
|
||||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
|
||||||
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
|
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
|
||||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
|
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
|
||||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy
|
import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy
|
||||||
@ -56,6 +54,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.hasHidde
|
|||||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
|
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
|
||||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests
|
import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.session.libsignal.utilities.Util
|
import org.session.libsignal.utilities.Util
|
||||||
|
@ -6,13 +6,13 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.AsyncTask
|
import android.os.AsyncTask
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import org.session.libsession.database.userAuth
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared
|
import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared
|
||||||
import org.session.libsession.messaging.messages.control.ReadReceipt
|
import org.session.libsession.messaging.messages.control.ReadReceipt
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender.send
|
import org.session.libsession.messaging.sending_receiving.MessageSender.send
|
||||||
import org.session.libsession.snode.SnodeAPI
|
import org.session.libsession.snode.SnodeAPI
|
||||||
import org.session.libsession.snode.SnodeAPI.nowWithOffset
|
import org.session.libsession.snode.SnodeAPI.nowWithOffset
|
||||||
import org.session.libsession.utilities.SSKEnvironment
|
import org.session.libsession.utilities.SSKEnvironment
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
|
||||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
|
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
|
||||||
import org.session.libsession.utilities.associateByNotNull
|
import org.session.libsession.utilities.associateByNotNull
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
@ -102,7 +102,7 @@ class MarkReadReceiver : BroadcastReceiver() {
|
|||||||
SnodeAPI.alterTtl(
|
SnodeAPI.alterTtl(
|
||||||
messageHashes = hashes,
|
messageHashes = hashes,
|
||||||
newExpiry = nowWithOffset + expiresIn,
|
newExpiry = nowWithOffset + expiresIn,
|
||||||
publicKey = TextSecurePreferences.getLocalNumber(context)!!,
|
auth = checkNotNull(shared.storage.userAuth) { "No authorized user" },
|
||||||
shorten = true
|
shorten = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -130,7 +130,7 @@ class MarkReadReceiver : BroadcastReceiver() {
|
|||||||
hashToMessage: Map<String, MarkedMessageInfo>
|
hashToMessage: Map<String, MarkedMessageInfo>
|
||||||
) {
|
) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map<String, Long>
|
val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), shared.storage.userAuth!!).get()["expiries"] as Map<String, Long>
|
||||||
hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } }
|
hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,54 +1,110 @@
|
|||||||
package org.thoughtcrime.securesms.notifications
|
package org.thoughtcrime.securesms.notifications
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat.getString
|
import androidx.core.content.ContextCompat.getString
|
||||||
import com.goterl.lazysodium.interfaces.AEAD
|
import com.goterl.lazysodium.interfaces.AEAD
|
||||||
import com.goterl.lazysodium.utils.Key
|
import com.goterl.lazysodium.utils.Key
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
|
import network.loki.messenger.libsession_util.util.GroupInfo
|
||||||
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
|
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
import org.session.libsession.messaging.jobs.MessageReceiveParameters
|
import org.session.libsession.messaging.jobs.MessageReceiveParameters
|
||||||
|
import org.session.libsession.messaging.messages.Destination
|
||||||
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata
|
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata
|
||||||
import org.session.libsession.messaging.utilities.MessageWrapper
|
import org.session.libsession.messaging.utilities.MessageWrapper
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
|
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
|
||||||
|
import org.session.libsession.snode.GroupSubAccountSwarmAuth
|
||||||
|
import org.session.libsession.snode.OwnedSwarmAuth
|
||||||
import org.session.libsession.utilities.bencode.Bencode
|
import org.session.libsession.utilities.bencode.Bencode
|
||||||
import org.session.libsession.utilities.bencode.BencodeList
|
import org.session.libsession.utilities.bencode.BencodeList
|
||||||
import org.session.libsession.utilities.bencode.BencodeString
|
import org.session.libsession.utilities.bencode.BencodeString
|
||||||
|
import org.session.libsession.utilities.withGroupConfigsOrNull
|
||||||
|
import org.session.libsignal.protos.SignalServiceProtos
|
||||||
|
import org.session.libsignal.protos.SignalServiceProtos.Envelope
|
||||||
|
import org.session.libsignal.utilities.AccountId
|
||||||
import org.session.libsignal.utilities.Base64
|
import org.session.libsignal.utilities.Base64
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.session.libsignal.utilities.Namespace
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val TAG = "PushHandler"
|
private const val TAG = "PushHandler"
|
||||||
|
|
||||||
class PushReceiver @Inject constructor(@ApplicationContext val context: Context) {
|
class PushReceiver @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val configFactory: ConfigFactory
|
||||||
|
) {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
fun onPush(dataMap: Map<String, String>?) {
|
fun onPush(dataMap: Map<String, String>?) {
|
||||||
onPush(dataMap?.asByteArray())
|
val result = dataMap?.decodeAndDecrypt()
|
||||||
}
|
val data = result?.first
|
||||||
|
|
||||||
fun onPush(data: ByteArray?) {
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
onPush()
|
onPush()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePushData(data = data, metadata = result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePushData(data: ByteArray, metadata: PushNotificationMetadata?) {
|
||||||
try {
|
try {
|
||||||
val envelopeAsData = MessageWrapper.unwrap(data).toByteArray()
|
val params = when {
|
||||||
val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null)
|
metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> {
|
||||||
JobQueue.shared.add(job)
|
val groupId = AccountId(requireNotNull(metadata.account) {
|
||||||
|
"Received a closed group message push notification without an account ID"
|
||||||
|
})
|
||||||
|
|
||||||
|
val envelop = checkNotNull(tryDecryptGroupMessage(groupId, data)) {
|
||||||
|
"Unable to decrypt closed group message"
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageReceiveParameters(
|
||||||
|
data = envelop.toByteArray(),
|
||||||
|
serverHash = metadata.msg_hash,
|
||||||
|
closedGroup = Destination.ClosedGroup(groupId.hexString)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata?.namespace == 0 || metadata == null -> {
|
||||||
|
MessageReceiveParameters(
|
||||||
|
data = MessageWrapper.unwrap(data).toByteArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "Received a push notification with an unknown namespace: ${metadata.namespace}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "Failed to unwrap data for message due to error.", e)
|
Log.d(TAG, "Failed to unwrap data for message due to error.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun tryDecryptGroupMessage(groupId: AccountId, data: ByteArray): Envelope? {
|
||||||
|
return configFactory.withGroupConfigsOrNull(groupId) { _, _, keys ->
|
||||||
|
val (envelopBytes, sender) = checkNotNull(keys.decrypt(data)) {
|
||||||
|
"Failed to decrypt group message"
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Successfully decrypted group message from ${sender.hexString}")
|
||||||
|
Envelope.parseFrom(envelopBytes)
|
||||||
|
.toBuilder()
|
||||||
|
.setSource(sender.hexString)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onPush() {
|
private fun onPush() {
|
||||||
Log.d(TAG, "Failed to decode data for message.")
|
Log.d(TAG, "Failed to decode data for message.")
|
||||||
val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER)
|
val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER)
|
||||||
@ -61,10 +117,13 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
|
|||||||
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
|
||||||
|
if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||||
NotificationManagerCompat.from(context).notify(11111, builder.build())
|
NotificationManagerCompat.from(context).notify(11111, builder.build())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Map<String, String>.asByteArray() =
|
private fun Map<String, String>.decodeAndDecrypt() =
|
||||||
when {
|
when {
|
||||||
// this is a v2 push notification
|
// this is a v2 push notification
|
||||||
containsKey("spns") -> {
|
containsKey("spns") -> {
|
||||||
@ -76,18 +135,20 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// old v1 push notification; we still need this for receiving legacy closed group notifications
|
// old v1 push notification; we still need this for receiving legacy closed group notifications
|
||||||
else -> this["ENCRYPTED_DATA"]?.let(Base64::decode)
|
else -> this["ENCRYPTED_DATA"]?.let { Base64.decode(it) to null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decrypt(encPayload: ByteArray): ByteArray? {
|
private fun decrypt(encPayload: ByteArray): Pair<ByteArray?, PushNotificationMetadata?> {
|
||||||
Log.d(TAG, "decrypt() called")
|
Log.d(TAG, "decrypt() called")
|
||||||
|
|
||||||
val encKey = getOrCreateNotificationKey()
|
val encKey = getOrCreateNotificationKey()
|
||||||
val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
|
val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES)
|
||||||
val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
|
val payload =
|
||||||
|
encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size)
|
||||||
val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce)
|
val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce)
|
||||||
?: error("Failed to decrypt push notification")
|
?: error("Failed to decrypt push notification")
|
||||||
val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray()
|
val contentEndedAt = padded.indexOfLast { it.toInt() != 0 }
|
||||||
|
val decrypted = if (contentEndedAt >= 0) padded.sliceArray(0..contentEndedAt) else padded
|
||||||
val bencoded = Bencode.Decoder(decrypted)
|
val bencoded = Bencode.Decoder(decrypted)
|
||||||
val expectedList = (bencoded.decode() as? BencodeList)?.values
|
val expectedList = (bencoded.decode() as? BencodeList)?.values
|
||||||
?: error("Failed to decode bencoded list from payload")
|
?: error("Failed to decode bencoded list from payload")
|
||||||
@ -99,20 +160,18 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
|
|||||||
// null content is valid only if we got a "data_too_long" flag
|
// null content is valid only if we got a "data_too_long" flag
|
||||||
it?.let { check(metadata.data_len == it.size) { "wrong message data size" } }
|
it?.let { check(metadata.data_len == it.size) { "wrong message data size" } }
|
||||||
?: check(metadata.data_too_long) { "missing message data, but no too-long flag" }
|
?: check(metadata.data_too_long) { "missing message data, but no too-long flag" }
|
||||||
}
|
} to metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOrCreateNotificationKey(): Key {
|
fun getOrCreateNotificationKey(): Key {
|
||||||
if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) {
|
val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY)
|
||||||
|
if (keyHex != null) {
|
||||||
|
return Key.fromHexString(keyHex)
|
||||||
|
}
|
||||||
|
|
||||||
// generate the key and store it
|
// generate the key and store it
|
||||||
val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
|
val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
|
||||||
IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
|
IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
|
||||||
}
|
return key
|
||||||
return Key.fromHexString(
|
|
||||||
IdentityKeyUtil.retrieve(
|
|
||||||
context,
|
|
||||||
IdentityKeyUtil.NOTIFICATION_KEY
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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