From d41496a997b472f6d9cc259a37fb7e41353242e7 Mon Sep 17 00:00:00 2001 From: fanchao Date: Fri, 6 Sep 2024 11:21:03 +1000 Subject: [PATCH 01/80] Strings work Squashed commit of the following: commit 86cab0e11e4871ec2258c2099d8634a91a2f9bea Author: ThomasSession Date: Fri Aug 30 10:17:04 2024 +1000 Bringing my xml dialog styling from my 'Standardise message deletion' branch commit 706d1aadd833f6fa60de8ac308c62919adf45dc4 Author: ThomasSession Date: Fri Aug 30 09:49:48 2024 +1000 fixing up clear data dialog Removing unused code commit f90599451f9660e4a64964481eacdf65070dc092 Author: Al Lansley Date: Fri Aug 30 09:13:51 2024 +1000 Replaced 'now' with 12/24 hour time commit 16b8ad46c09515de949f0f47a0ef16f799a7e878 Author: alansley Date: Thu Aug 29 17:34:03 2024 +1000 Fix two one-liner issues commit 4c6c450b3218a0c3663ede1773b6dc32989024fc Merge: 052f910d69 beb89d5b74 Author: ThomasSession 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 Date: Thu Aug 29 17:06:53 2024 +1000 More bold fixing commit beb89d5b74b8a64ffcf9c7ce3d7507a9b83dac9e Author: fanchao Date: Thu Aug 29 17:00:37 2024 +1000 Fix incorrect group member left message commit 5773f05a5c461fba8c91bb804be17f0245e6ee79 Merge: d35482daba 1cec477020 Author: ThomasSession 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 Date: Thu Aug 29 15:20:13 2024 +1000 More bold fixes and UI tweaks commit 78a9ab7159218005f5bca91b35583e4daa608e2d Author: ThomasSession Date: Thu Aug 29 14:03:41 2024 +1000 Making sure we bold appropriately commit 1cec4770203a61547356009e42bf80e65fe17410 Author: alansley 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 Date: Thu Aug 29 13:28:54 2024 +1000 Using the existing implementation commit cb9554ab386af1d01177940cac10283be2944ce2 Author: alansley Date: Thu Aug 29 12:32:30 2024 +1000 Merge CrowdIn strings circa 2024-08-29 commit dd57da70f64eb622482eea4f3c4a78e233d96d28 Author: alansley Date: Thu Aug 29 09:06:22 2024 +1000 Updated Phrase usage in ConversationAdapter commit 34b15d78656a9c82d7952da9d003df686e231f81 Author: alansley Date: Thu Aug 29 09:03:55 2024 +1000 Converted TransferControlView into Kotlin and updated Phrase usage commit a35a7a6a96cf68f7b44749f1f3482adac5b1d17e Author: alansley Date: Thu Aug 29 08:55:16 2024 +1000 Converted MessageReceipientNotificationBuilder to Kotlin & updated Phrase usage commit 6dd93b33f222c0818073ff3fff02c312b3b9d2e9 Author: alansley Date: Thu Aug 29 08:25:24 2024 +1000 Update MuteDialog, LinkPreviewDialog, and PathActivity commit e7dd1c582d1ceb4bdf132ca068264badc70bccb4 Author: alansley Date: Thu Aug 29 08:16:09 2024 +1000 Updated DisappearingMessages.kt and HelpSettingsActivity.kt commit 5bd55ea99320941b8f9b40f0680d6980f8e09dc4 Author: alansley 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 Date: Thu Aug 29 07:15:03 2024 +1000 Removed R.string.gif and replaced with a string constant commit ace58e3493ec3a5991274ec8d2554ff1eea6cf8e Author: alansley Date: Thu Aug 29 07:11:53 2024 +1000 getSubbedString correction commit 2a8f010369424ff5a7138c9294283478e31c424e Merge: ce8efd7def 116bef3c71 Author: alansley Date: Wed Aug 28 16:31:43 2024 +1000 Merge branch 'compose-open-url-dialog' into strings-squashed commit ce8efd7def0a25515a06fea3b1dabf90cc4909c2 Author: alansley Date: Wed Aug 28 16:31:11 2024 +1000 WIP commit 114066ad5f841dfc0e8e68adc29f61abfc804f21 Author: alansley 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 Date: Wed Aug 28 15:25:03 2024 +1000 For safety commit 0b1a71a5820901a010b633525f56988e8b5095cd Author: ThomasSession Date: Wed Aug 28 15:23:02 2024 +1000 Cleaning other use of old url dialog commit 20abbebf4ac8bc3a8fd46463f3be621b991c15d1 Author: ThomasSession Date: Wed Aug 28 15:19:46 2024 +1000 Forgot !! commit 25132c6342f11613083b9cd3f7413b775faefc00 Author: ThomasSession Date: Wed Aug 28 15:13:58 2024 +1000 Proper set up for the Open URL dialog commit 1f68791da92287e52c7b1e95256ccb771f77a31b Author: alansley Date: Wed Aug 28 14:35:05 2024 +1000 Replaced placeholder text with new string commit 8d97f31b4d5bf79d7f69887ca871210e431c40e5 Author: alansley Date: Wed Aug 28 14:31:52 2024 +1000 Adjusted comment commit dfebe6f3f97c6ea96d2b143291ae5991d7242104 Author: alansley 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 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 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 Date: Wed Aug 28 09:51:58 2024 +1000 Merge branch 'dev' into strings-squashed commit 2aa58f4dd6c62ec712715a24cf86272c0990a7af Author: alansley Date: Wed Aug 28 08:27:03 2024 +1000 WIP compose openURL dialog commit 5df981bc7ab4736e1a96ef4f585f063189f95740 Author: alansley 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 Date: Tue Aug 27 15:42:33 2024 +1000 Added some TODO markers for tomorrow commit a402a1be79a5e6ddebb65cc5bca2e41310f4f94e Author: alansley Date: Tue Aug 27 15:33:55 2024 +1000 Adjusted Landing page string substitutions to cater for emojis commit 4809b5444b7b3488e58e6b6e96e5d17f77d545b0 Author: alansley Date: Tue Aug 27 15:12:39 2024 +1000 Removed unused 'isEmpty' utility methods commit b52048a080ac5c9cf6217d5f519c79aa48c873b6 Author: alansley Date: Tue Aug 27 14:42:57 2024 +1000 Addressed many aspects of PR feedback + misc. strings issues commit 9cdbc4b80b80368d42635144bc37b3ee2689f06b Author: alansley 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 Date: Tue Aug 27 08:19:53 2024 +1000 Merge branch 'dev' into strings-squashed commit 3c576053a3e5717b7beeef2286837be4951e355f Author: alansley 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 Date: Mon Aug 26 11:54:09 2024 +1000 Merge branch 'dev' into strings-squashed commit 404fb8001cfe84b44bd76decb43dd0fa93040c25 Author: alansley 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 Date: Fri Aug 23 14:13:11 2024 +1000 Cleaned up HomeActivityTests.kt commit 5f82571befba7ec830c60064fefe553aac307cd6 Merge: 69b8bd7396 8deb21c0c6 Author: Al Lansley Date: Fri Aug 23 08:59:21 2024 +1000 Merge branch 'dev' into strings-squashed commit 69b8bd739690f51540490d943b06f92ccb0a323a Author: alansley 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 Date: Thu Aug 22 14:26:48 2024 +1000 SS-75 Prevented ScrollView vertical scroll bar from fading out commit b0b835092dffab3a112f61f203dd9138a9a1c9b1 Author: alansley 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 Date: Thu Aug 22 13:43:00 2024 +1000 Merge branch 'dev' into strings-squashed commit efc2ee2824494169e383978819501d2edaa061d4 Author: alansley Date: Thu Aug 22 13:40:59 2024 +1000 Added some comments about the new CrowdIn strings commit 7a03fb37ef34726d268e467d51bfc83905609483 Author: alansley Date: Thu Aug 22 13:08:03 2024 +1000 Initial integration of CrowdIn strings (English only) commit 9766c3fd0b9200323584f15fbc004d9bc1b0987f Author: alansley 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 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 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 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 Date: Wed Aug 21 13:28:16 2024 +1000 Merge branch 'dev' into strings-squashed commit 2338bb47ca1dea1deb232c86978157a9f01fe44c Author: alansley 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 Date: Tue Aug 20 17:53:27 2024 +1000 Added a note about the plurals for search results commit f7748a0c05eb1272a7b281d6910691df2130c3b0 Author: alansley Date: Tue Aug 20 16:06:24 2024 +1000 Corrected text on storage permission dialog commit f6b62565989fa78b493c48a8a8efe9b9284c29d9 Author: alansley Date: Tue Aug 20 14:44:25 2024 +1000 Minor cleanup of BlockedContactsActivity commit e3d4870d81bd54f2f2373cc5d969ad2c406ddf89 Author: alansley 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 Date: Tue Aug 20 13:27:35 2024 +1000 Merge branch 'dev' into strings-squashed commit 5e02e1ef5c04056761409c97ba90efc5b447bb6c Author: alansley Date: Tue Aug 20 09:39:16 2024 +1000 Added 'NonTranslatableStringConstants' file commit 816f21bb29e00633285cf084e314f4375eec31dc Author: alansley 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 Date: Mon Aug 19 16:22:08 2024 +1000 SES-1571 Large messages show warning toast commit 27ca77d5c48b097d8e6b397414a09383a4645fc2 Merge: 27bc90bf1f f379604c54 Author: Al Lansley Date: Mon Aug 19 11:19:27 2024 +1000 Merge branch 'dev' into strings-squashed commit 27bc90bf1f21ad3cba8c11e6318c51a083736f01 Author: Al Lansley Date: Mon Aug 19 08:59:38 2024 +1000 Cleaned up some comments and content description commit 558684a56d9e609030242d411424def9f21b510a Merge: 90d7064c18 93a28906fb Author: Al Lansley Date: Mon Aug 19 08:41:47 2024 +1000 Merge branch 'dev' into strings-squashed commit 90d7064c18d0d95cbeb1f3fd04831fb8d36e2d0c Author: Al Lansley 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 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 Date: Thu Aug 15 09:38:10 2024 +1000 Merge branch 'dev' into strings-squashed commit 01009cf521e4fe8cb94f25beed48cd6e550a5b4a Author: Al Lansley 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 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 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 Date: Wed Aug 14 16:02:16 2024 +1000 SS-78 / SES-199 Mechanism required to limit emoji reaction rate commit a8ee5c9f3b0b121ca597fee5fc11cc5acb768ba0 Author: alansley Date: Wed Aug 14 14:51:40 2024 +1000 Replaced hard-coded 'Session' with '{app_name}' in 'callsSessionCall' commit 621094ebe4cb8c51ca4595b041eb94e5d4d469aa Author: alansley 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 Date: Tue Aug 13 15:50:35 2024 +1000 SS-75 Open URL modal change commit 802cf19598e83709303199a1d361416a557daac2 Author: Al Lansley Date: Mon Aug 12 16:42:15 2024 +1000 Open or copy URL WIP commit ea84aa1478081095df6a0e6c120bc7e29100dd4a Author: Al Lansley Date: Mon Aug 12 14:17:04 2024 +1000 Tied in bandDeleteAll string commit 93b8e74f2d1489ea7c9127cea940300f020b9a11 Author: Al Lansley 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 Date: Mon Aug 12 09:49:57 2024 +1000 Further AccessibilityId mapping & fixed group members counts to display correct details commit 558d6741b159a21c4f1a12262a08101a391daab2 Author: alansley Date: Fri Aug 9 17:24:44 2024 +1000 End of day push commit 73fdb16214c8f6c76b3e06c9e804e50a8961c032 Author: alansley Date: Fri Aug 9 15:57:06 2024 +1000 Localised time strings working - even if the unit tests aren't commit 436175d146db7add793fc8ebce31503ce1d6c844 Author: alansley Date: Fri Aug 9 13:54:09 2024 +1000 Relative time string WIP commit f309263e39fe9d68b4665d3ebf60a5debc5bd81d Merge: 45c4118d52 007e705cd9 Author: alansley Date: Fri Aug 9 11:39:13 2024 +1000 Merge dev commit 45c4118d526a54b2aa7b332adef04c49b7a77205 Author: Al Lansley Date: Thu Aug 8 16:43:02 2024 +1000 Further AccessibilityId mapping WIP commit 31bac8e30e0cf37b917fb847d913cd40a0109d0e Author: Al Lansley Date: Thu Aug 8 10:53:30 2024 +1000 Further accessibility ID changes & removed fragment_new_conversation_home.xml commit 9c2111e66e2ac09e5e210876665886bc9acb7d27 Author: alansley Date: Wed Aug 7 13:13:52 2024 +1000 AccessibilityId WIP commit 1e9eeff86adff3af64b515de4682a147f3078a55 Author: alansley Date: Wed Aug 7 11:06:39 2024 +1000 AccessibilityId adjustments & removed some unused XML layouts commit e5fd2c8cc03535bfd02fe7cd28eba51530ec7985 Author: alansley Date: Wed Aug 7 09:22:14 2024 +1000 AccessibilityId refactor WIP commit 399796bac34ca9ebbeb7cd37c030cca05da70dda Author: alansley Date: Tue Aug 6 15:51:53 2024 +1000 AccessibilityId WIP - up to AccessibilityId_reveal_recovery_phrase_button commit a8d72dfcc073530fab923f95d08dc10c15852e03 Author: alansley Date: Tue Aug 6 14:12:10 2024 +1000 Cleaned up a few comments and fixed some plurals logic commit be400d8f4f9289de26d70eafa001f16fc039e7e0 Author: alansley Date: Tue Aug 6 11:32:08 2024 +1000 Removed commented out merge conflict marker commit 5cbe289a8d562f7e187f6e6e494b26e88094c5e0 Merge: 5fe123e7b5 d6c5ab2b18 Author: alansley Date: Tue Aug 6 11:30:50 2024 +1000 Merge dev and cleanup commit 5fe123e7b54dfa4f5056af00e5440f01ec226a4e Author: Al Lansley 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 Date: Mon Aug 5 13:30:03 2024 +1000 Merge branch 'dev' into strings-squashed commit 00552930e604176f2dd679e9c8e956030078d39c Author: Al Lansley Date: Mon Aug 5 13:28:55 2024 +1000 Removed unused helpReportABugDesktop strings commit 6c0450b487b17e99e87805ada59a8bb583b86fac Author: Al Lansley Date: Mon Aug 5 12:59:15 2024 +1000 Renamed 'quitButton' string to just 'quit' commit 284c4859038362b8ef065d08507c43ccd444d27c Author: Al Lansley 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 Date: Mon Aug 5 10:45:05 2024 +1000 WIP commit bc94cb78db54b39c7276014809e55378d38056a0 Author: alansley Date: Fri Aug 2 16:21:16 2024 +1000 End of day push commit 1a2df3798ae285c1f61061ecf2fc423065490f98 Merge: c7fdb6aed9 a56e1d0b91 Author: alansley Date: Fri Aug 2 15:20:19 2024 +1000 Merged dev commit c7fdb6aed94544dcbef278d26702f8d093bdd91c Author: alansley Date: Fri Aug 2 14:21:11 2024 +1000 Replaced string 'dialog_disappearing_messages_follow_setting_confirm' with 'confirm' commit 2992d590d9c1a5007941e17a404d692b89aa8899 Author: alansley 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 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 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 Date: Wed Jul 31 13:08:04 2024 +1000 Merge branch 'dev' into strings-squashed commit 608c984a6b550e18423d0159fa746af7fe5be426 Author: alansley Date: Tue Jul 30 16:58:08 2024 +1000 Actually remove the 4 specific time period mute strings commit 006a4e8bad85db54b25e3edfc5ed02c05ce834fe Author: alansley Date: Tue Jul 30 16:43:54 2024 +1000 Cleaned up MuteDialog.kt commit d3177f9f1a85ca772873fd4ec4f14d0d84c58e73 Author: alansley 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 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 Date: Tue Jul 30 11:03:46 2024 +1000 Changed some hard-coded 'Session' text in strings and renamed another commit bc90d18c91e2a2dbb15c090b5d9d7e2fd02a2acf Author: alansley 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 Date: Tue Jul 30 08:16:02 2024 +1000 Merge branch 'dev' into strings-squashed commit 3b62e474b37bef9530ae7e74d28312c902653b1b Author: Al Lansley Date: Mon Jul 29 16:33:21 2024 +1000 Down to just the final few straggler strings commit 13e81f046b7a781d8e8491170f52951f93353fce Author: Al Lansley Date: Mon Jul 29 13:13:54 2024 +1000 WIP commit 2d9961d5c0e27332ab87a37e7e263645e490f51c Author: Al Lansley Date: Mon Jul 29 08:58:01 2024 +1000 Further cleanup of stragglers commit 08b8a84309a8c91fb71cad313d52be565d86d3fd Author: Al Lansley Date: Mon Jul 29 08:29:12 2024 +1000 Cleaning up straggler strings commit d0e87c64b594f34579f1dcd4884801374dc6dbd1 Author: alansley Date: Fri Jul 26 17:07:46 2024 +1000 WIP commit 4bc9d09be2ffd5eecfa0d0fad6a9bc53f019307c Author: alansley Date: Fri Jul 26 16:30:28 2024 +1000 WIP commit 3cee4bc12f778a3b9a5560092430303ba3f12a0b Merge: aa1db13e3a a495ec232a Author: alansley Date: Fri Jul 26 13:57:09 2024 +1000 Removed some legacy strings & substituted others commit aa1db13e3a254c2b2972ba3040db087b16644033 Author: fanchao Date: Fri Jul 26 11:34:05 2024 +1000 Initial squash merge for strings --- .drone.jsonnet | 4 +- .drone.yml | 10 + .gitignore | 1 + app/build.gradle | 44 +- app/src/androidTest/AndroidManifest.xml | 3 + .../loki/messenger/CreateGroupTests.kt | 143 + .../network/loki/messenger/EditGroupTests.kt | 257 + .../loki/messenger/HomeActivityTests.kt | 46 +- .../network/loki/messenger/LibSessionTests.kt | 36 +- .../loki/messenger/util/LoginAutomation.kt | 82 + .../loki/messenger/util/StorageUtility.kt | 31 + app/src/main/AndroidManifest.xml | 16 +- .../securesms/ApplicationContext.java | 50 +- .../securesms/MediaPreviewActivity.java | 1 + .../securesms/SessionDialogBuilder.kt | 4 +- .../attachments/DatabaseAttachmentProvider.kt | 18 +- .../components/ProfilePictureView.kt | 6 +- .../components/menu/ContextMenuList.kt | 1 - .../contacts/ContactSelectionListLoader.kt | 2 +- .../conversation/ConversationActionBarView.kt | 15 +- .../DisappearingMessages.kt | 11 +- .../DisappearingMessagesViewModel.kt | 24 +- ...onversationNotificationSettingsActivity.kt | 59 + ...ionNotificationSettingsActivityContract.kt | 16 + .../settings/ConversationSettingsActivity.kt | 269 + .../ConversationSettingsActivityContract.kt | 24 + .../settings/ConversationSettingsViewModel.kt | 104 + .../start/NewConversationFragment.kt | 2 +- .../start/invitefriend/InviteFriend.kt | 1 + .../start/newmessage/NewMessage.kt | 8 +- .../conversation/v2/ConversationActivityV2.kt | 197 +- .../conversation/v2/ConversationViewModel.kt | 224 +- .../v2/DeleteOptionsBottomSheet.kt | 9 +- .../conversation/v2/MessageDetailActivity.kt | 3 +- .../conversation/v2/dialogs/DownloadDialog.kt | 45 +- .../conversation/v2/input_bar/InputBar.kt | 25 +- .../v2/mention/MentionViewModel.kt | 5 +- .../menus/ConversationActionModeCallback.kt | 4 +- .../v2/menus/ConversationMenuHelper.kt | 14 +- .../v2/messages/ControlMessageView.kt | 11 +- .../v2/messages/PendingAttachmentView.kt | 65 + .../v2/messages/UntrustedAttachmentView.kt | 56 - .../v2/messages/VisibleMessageContentView.kt | 72 +- .../v2/messages/VisibleMessageView.kt | 1 - .../securesms/crypto/IdentityKeyUtil.java | 10 + .../securesms/database/ConfigDatabase.kt | 51 + .../ExpirationConfigurationDatabase.kt | 6 +- .../securesms/database/LokiMessageDatabase.kt | 75 + .../securesms/database/MediaDatabase.java | 6 +- .../securesms/database/MessagingDatabase.java | 18 +- .../securesms/database/MmsDatabase.kt | 128 +- .../securesms/database/MmsSmsDatabase.java | 19 + .../securesms/database/RecipientDatabase.java | 98 +- .../database/SessionContactDatabase.kt | 23 +- .../securesms/database/SessionJobDatabase.kt | 8 + .../securesms/database/SmsDatabase.java | 36 +- .../securesms/database/Storage.kt | 1267 +- .../securesms/database/ThreadDatabase.java | 96 +- .../database/helpers/SQLCipherOpenHelper.java | 19 +- .../database/model/MessageRecord.java | 2 +- .../database/model/ThreadRecord.java | 109 +- .../securesms/debugmenu/DebugMenuViewModel.kt | 2 +- .../securesms/dependencies/AppModule.kt | 17 + .../securesms/dependencies/CallModule.kt | 8 +- .../securesms/dependencies/ConfigFactory.kt | 227 +- .../dependencies/DatabaseBindings.kt | 17 + .../dependencies/DatabaseComponent.kt | 1 + .../securesms/dependencies/DatabaseModule.kt | 9 +- .../securesms/dependencies/PollerFactory.kt | 48 + .../dependencies/SessionUtilModule.kt | 23 + .../securesms/groups/ClosedGroupManager.kt | 13 +- .../securesms/groups/CreateGroupFragment.kt | 129 +- .../securesms/groups/CreateGroupViewModel.kt | 97 +- .../securesms/groups/EditGroupActivity.kt | 35 + .../groups/EditGroupInviteViewModel.kt | 31 + .../securesms/groups/EditGroupViewModel.kt | 260 + ...ader.kt => EditLegacyClosedGroupLoader.kt} | 6 +- ...Activity.kt => EditLegacyGroupActivity.kt} | 37 +- ...er.kt => EditLegacyGroupMembersAdapter.kt} | 4 +- .../securesms/groups/GroupMemberSelection.kt | 2 + .../groups/SelectContactsViewModel.kt | 132 + .../securesms/groups/compose/Components.kt | 168 + .../groups/compose/CreateGroupScreen.kt | 169 + .../groups/compose/EditGroupScreen.kt | 504 + .../groups/compose/SelectContactsScreen.kt | 146 + .../home/ConversationOptionsBottomSheet.kt | 9 + .../securesms/home/ConversationView.kt | 12 +- .../securesms/home/HomeActivity.kt | 56 +- .../securesms/home/HomeViewModel.kt | 2 +- .../home/search/GlobalSearchAdapter.kt | 11 +- .../home/search/GlobalSearchAdapterUtils.kt | 17 +- .../MessageRequestsActivity.kt | 13 +- .../messagerequests/MessageRequestsAdapter.kt | 14 +- .../MessageRequestsViewModel.kt | 11 +- .../notifications/BackgroundPollWorker.kt | 19 +- .../notifications/DefaultMessageNotifier.kt | 3 +- .../notifications/MarkReadReceiver.kt | 6 +- .../securesms/notifications/PushReceiver.kt | 113 +- .../notifications/PushRegistrationHandler.kt | 235 + .../securesms/notifications/PushRegistry.kt | 111 - .../securesms/notifications/PushRegistryV2.kt | 129 +- .../securesms/notifications/TokenFetcher.kt | 5 - .../securesms/notifications/TokenManager.kt | 32 - .../onboarding/loadaccount/LoadAccount.kt | 7 +- .../MessageNotificationsViewModel.kt | 5 - .../onboarding/pickname/PickDisplayName.kt | 5 +- .../preferences/BlockedContactsViewModel.kt | 3 +- .../preferences/ClearAllDataDialog.kt | 13 +- .../NotificationsPreferenceFragment.kt | 21 +- .../widgets/SignalListPreference.java | 84 +- .../reactions/ReactionRecipientsAdapter.java | 2 + .../RecoveryPasswordActivity.kt | 2 +- .../repository/ConversationRepository.kt | 302 +- .../service/ExpiringMessageManager.kt | 25 +- .../sskenvironment/ProfileManager.kt | 2 +- .../thoughtcrime/securesms/ui/Components.kt | 227 +- .../securesms/ui/components/QR.kt | 2 +- .../securesms/ui/components/Text.kt | 112 +- .../thoughtcrime/securesms/ui/theme/Colors.kt | 2 + .../securesms/ui/theme/ThemeColors.kt | 10 + .../securesms/util/AttachmentUtil.kt | 35 + .../util/ConfigurationMessageUtilities.kt | 69 +- .../securesms/util/SharedConfigUtils.kt | 4 +- .../main/res/drawable/avatar_placeholder.xml | 18 + app/src/main/res/drawable/debug_border.xml | 4 + app/src/main/res/drawable/ic_add_admins.xml | 16 + app/src/main/res/drawable/ic_all_media.xml | 13 + .../res/drawable/ic_baseline_logout_24.xml | 10 + .../main/res/drawable/ic_clear_messages.xml | 20 + .../res/drawable/ic_disappearing_messages.xml | 16 + app/src/main/res/drawable/ic_edit_group.xml | 22 + app/src/main/res/drawable/ic_leave_group.xml | 13 + app/src/main/res/drawable/ic_link_out.xml | 12 + app/src/main/res/drawable/ic_media.xml | 18 + .../res/drawable/ic_notification_settings.xml | 9 + .../main/res/drawable/ic_pin_conversation.xml | 13 + .../main/res/drawable/ic_question_mark.xml | 15 + .../res/drawable/ic_search_conversation.xml | 9 + .../drawable/preference_single_no_padding.xml | 9 + .../profile_picture_view_large_background.xml | 9 + ...ity_conversation_notification_settings.xml | 73 + .../layout/activity_conversation_settings.xml | 375 + .../res/layout/activity_conversation_v2.xml | 45 +- .../res/layout/activity_edit_closed_group.xml | 2 +- .../fragment_conversation_bottom_sheet.xml | 7 + .../main/res/layout/view_control_message.xml | 2 +- .../res/layout/view_large_profile_picture.xml | 47 + .../res/layout/view_pending_attachment.xml | 48 + .../res/layout/view_untrusted_attachment.xml | 31 - .../layout/view_visible_message_content.xml | 4 +- app/src/main/res/menu/menu_group_request.xml | 9 + app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/dimens.xml | 3 +- app/src/main/res/values/ids.xml | 3 + app/src/main/res/values/styles.xml | 48 + .../xml/network_security_configuration.xml | 4 +- .../notifications/FirebasePushModule.kt | 1 + .../notifications/FirebasePushService.kt | 17 +- .../notifications/FirebaseTokenFetcher.kt | 18 +- .../DisappearingMessagesViewModelTest.kt | 3 +- .../v2/ConversationSettingsViewModelTest.kt | 95 + .../v2/ConversationViewModelTest.kt | 28 +- .../conversation/v2/MentionViewModelTest.kt | 2 +- build.gradle | 10 +- .../src/main/res/values/strings.xml | 2 + gradle.properties | 14 +- gradle/wrapper/gradle-wrapper.properties | 5 +- libsession-util/build.gradle | 6 +- .../libsession_util/InstrumentedTests.kt | 239 +- libsession-util/src/main/cpp/CMakeLists.txt | 6 +- libsession-util/src/main/cpp/config_base.h | 6 + libsession-util/src/main/cpp/contacts.h | 8 +- libsession-util/src/main/cpp/conversation.cpp | 57 + libsession-util/src/main/cpp/conversation.h | 35 + libsession-util/src/main/cpp/group_info.cpp | 213 + libsession-util/src/main/cpp/group_info.h | 12 + libsession-util/src/main/cpp/group_keys.cpp | 300 + libsession-util/src/main/cpp/group_keys.h | 12 + .../src/main/cpp/group_members.cpp | 103 + libsession-util/src/main/cpp/group_members.h | 13 + libsession-util/src/main/cpp/user_groups.cpp | 104 +- libsession-util/src/main/cpp/user_groups.h | 66 +- libsession-util/src/main/cpp/user_profile.cpp | 4 +- libsession-util/src/main/cpp/util.cpp | 289 +- libsession-util/src/main/cpp/util.h | 12 +- .../loki/messenger/libsession_util/Config.kt | 179 +- .../messenger/libsession_util/util/Contact.kt | 2 +- .../libsession_util/util/Conversation.kt | 6 + .../libsession_util/util/GroupDisplayInfo.kt | 14 + .../libsession_util/util/GroupInfo.kt | 52 +- .../libsession_util/util/GroupMember.kt | 55 + .../messenger/libsession_util/util/Sodium.kt | 18 + libsession/build.gradle | 13 +- .../database/MessageDataProvider.kt | 1 + .../database/ServerHashToMessageId.kt | 8 + .../libsession/database/StorageProtocol.kt | 57 +- .../libsession/database/StorageProtocolExt.kt | 15 + .../messaging/MessagingModuleConfiguration.kt | 9 +- .../libsession/messaging/contacts/Contact.kt | 28 +- .../messaging/file_server/FileServerApi.kt | 2 +- .../groups/RemoveGroupMemberHandler.kt | 201 + .../messaging/jobs/AttachmentDownloadJob.kt | 10 +- .../messaging/jobs/BatchMessageReceiveJob.kt | 273 +- .../messaging/jobs/ConfigurationSyncJob.kt | 350 +- .../messaging/jobs/GroupLeavingJob.kt | 108 + .../messaging/jobs/InviteContactsJob.kt | 199 + .../libsession/messaging/jobs/JobQueue.kt | 20 +- .../jobs/LibSessionGroupLeavingJob.kt | 63 + .../messaging/jobs/MessageReceiveJob.kt | 2 +- .../messaging/messages/Destination.kt | 12 +- .../libsession/messaging/messages/Message.kt | 9 +- .../messaging/messages/control/CallMessage.kt | 9 +- .../control/ClosedGroupControlMessage.kt | 3 +- .../messages/control/ConfigurationMessage.kt | 4 +- .../control/DataExtractionNotification.kt | 2 + .../messages/control/ExpirationTimerUpdate.kt | 15 +- .../messages/control/GroupUpdated.kt | 35 + .../control/MessageRequestResponse.kt | 2 + .../messaging/messages/control/ReadReceipt.kt | 2 + .../control/SharedConfigurationMessage.kt | 1 + .../messages/control/TypingIndicator.kt | 2 + .../messages/control/UnsendRequest.kt | 2 + .../messages/signal/IncomingGroupMessage.java | 4 +- .../messages/signal/IncomingMediaMessage.java | 21 +- .../messages/signal/IncomingTextMessage.java | 14 +- .../messages/visible/VisibleMessage.kt | 18 +- .../messaging/notifications/TokenFetcher.kt | 15 + .../messaging/open_groups/OpenGroupApi.kt | 5 +- .../messaging/open_groups/OpenGroupMessage.kt | 2 +- .../sending_receiving/MessageDecrypter.kt | 6 +- .../sending_receiving/MessageEncrypter.kt | 4 +- .../sending_receiving/MessageReceiver.kt | 97 +- .../sending_receiving/MessageSender.kt | 163 +- .../MessageSenderClosedGroupHandler.kt | 41 +- .../ReceivedMessageHandler.kt | 224 +- .../attachments/Attachment.java | 3 +- .../attachments/DatabaseAttachment.java | 5 +- .../sending_receiving/notifications/Models.kt | 10 +- .../notifications/PushRegistryV1.kt | 89 +- .../pollers/ClosedGroupPoller.kt | 376 + ...llerV2.kt => LegacyClosedGroupPollerV2.kt} | 18 +- .../pollers/OpenGroupPoller.kt | 2 +- .../sending_receiving/pollers/Poller.kt | 41 +- .../utilities/MessageAuthentication.kt | 39 + .../messaging/utilities/MessageWrapper.kt | 10 +- .../messaging/utilities/SodiumUtilities.kt | 79 +- .../utilities/UpdateMessageBuilder.kt | 160 +- .../messaging/utilities/UpdateMessageData.kt | 117 +- .../snode/GroupSubAccountSwarmAuth.kt | 39 + .../libsession/snode/OwnedSwarmAuth.kt | 49 + .../org/session/libsession/snode/SnodeAPI.kt | 701 +- .../session/libsession/snode/SnodeMessage.kt | 6 +- .../org/session/libsession/snode/SwarmAuth.kt | 23 + .../libsession/snode/model/BatchResponse.kt | 13 + .../libsession/snode/utilities/PromiseUtil.kt | 45 + .../session/libsession/utilities/Address.kt | 8 +- .../utilities/ConfigFactoryProtocol.kt | 63 +- .../libsession/utilities/GroupRecord.kt | 4 +- .../session/libsession/utilities/GroupUtil.kt | 16 +- .../libsession/utilities/SSKEnvironment.kt | 2 - .../utilities/TextSecurePreferences.kt | 53 +- .../session/libsession/utilities/Toaster.kt | 7 + .../utilities/recipients/Recipient.java | 196 +- .../recipients/RecipientProvider.java | 4 +- libsession/src/main/res/values/dimens.xml | 2 - libsignal/build.gradle | 7 +- libsignal/protobuf/Makefile | 2 +- libsignal/protobuf/SignalService.proto | 157 +- .../messages/SignalServiceGroup.java | 11 +- .../libsignal/protos/SignalProtos.java | 2959 - .../libsignal/protos/SignalServiceProtos.java | 47808 ++++++++++------ .../session/libsignal/protos/UtilProtos.java | 435 +- .../libsignal/protos/WebSocketProtos.java | 2106 +- .../session/libsignal/utilities/AccountId.kt | 28 + .../session/libsignal/utilities/IdPrefix.kt | 2 +- .../session/libsignal/utilities/Namespace.kt | 15 +- .../session/libsignal/utilities/Retrying.kt | 21 + .../org/session/libsignal/utilities/Snode.kt | 4 +- settings.gradle | 8 + 279 files changed, 45021 insertions(+), 23533 deletions(-) create mode 100644 .drone.yml create mode 100644 app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt create mode 100644 app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt create mode 100644 app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt create mode 100644 app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupInviteViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt rename app/src/main/java/org/thoughtcrime/securesms/groups/{EditClosedGroupLoader.kt => EditLegacyClosedGroupLoader.kt} (71%) rename app/src/main/java/org/thoughtcrime/securesms/groups/{EditClosedGroupActivity.kt => EditLegacyGroupActivity.kt} (91%) rename app/src/main/java/org/thoughtcrime/securesms/groups/{EditClosedGroupMembersAdapter.kt => EditLegacyGroupMembersAdapter.kt} (95%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt create mode 100644 app/src/main/res/drawable/avatar_placeholder.xml create mode 100644 app/src/main/res/drawable/debug_border.xml create mode 100644 app/src/main/res/drawable/ic_add_admins.xml create mode 100644 app/src/main/res/drawable/ic_all_media.xml create mode 100644 app/src/main/res/drawable/ic_baseline_logout_24.xml create mode 100644 app/src/main/res/drawable/ic_clear_messages.xml create mode 100644 app/src/main/res/drawable/ic_disappearing_messages.xml create mode 100644 app/src/main/res/drawable/ic_edit_group.xml create mode 100644 app/src/main/res/drawable/ic_leave_group.xml create mode 100644 app/src/main/res/drawable/ic_link_out.xml create mode 100644 app/src/main/res/drawable/ic_media.xml create mode 100644 app/src/main/res/drawable/ic_notification_settings.xml create mode 100644 app/src/main/res/drawable/ic_pin_conversation.xml create mode 100644 app/src/main/res/drawable/ic_question_mark.xml create mode 100644 app/src/main/res/drawable/ic_search_conversation.xml create mode 100644 app/src/main/res/drawable/preference_single_no_padding.xml create mode 100644 app/src/main/res/drawable/profile_picture_view_large_background.xml create mode 100644 app/src/main/res/layout/activity_conversation_notification_settings.xml create mode 100644 app/src/main/res/layout/activity_conversation_settings.xml create mode 100644 app/src/main/res/layout/view_large_profile_picture.xml create mode 100644 app/src/main/res/layout/view_pending_attachment.xml delete mode 100644 app/src/main/res/layout/view_untrusted_attachment.xml create mode 100644 app/src/main/res/menu/menu_group_request.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationSettingsViewModelTest.kt create mode 100644 libsession-util/src/main/cpp/group_info.cpp create mode 100644 libsession-util/src/main/cpp/group_info.h create mode 100644 libsession-util/src/main/cpp/group_keys.cpp create mode 100644 libsession-util/src/main/cpp/group_keys.h create mode 100644 libsession-util/src/main/cpp/group_members.cpp create mode 100644 libsession-util/src/main/cpp/group_members.h create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupDisplayInfo.kt create mode 100644 libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupMember.kt create mode 100644 libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt create mode 100644 libsession/src/main/java/org/session/libsession/database/StorageProtocolExt.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/groups/RemoveGroupMemberHandler.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/jobs/GroupLeavingJob.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/jobs/LibSessionGroupLeavingJob.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/notifications/TokenFetcher.kt create mode 100644 libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPoller.kt rename libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/{ClosedGroupPollerV2.kt => LegacyClosedGroupPollerV2.kt} (89%) create mode 100644 libsession/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/GroupSubAccountSwarmAuth.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/SwarmAuth.kt create mode 100644 libsession/src/main/java/org/session/libsession/snode/model/BatchResponse.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/Toaster.kt delete mode 100644 libsignal/src/main/java/org/session/libsignal/protos/SignalProtos.java create mode 100644 libsignal/src/main/java/org/session/libsignal/utilities/AccountId.kt diff --git a/.drone.jsonnet b/.drone.jsonnet index dc81115ce92..fcc0880394f 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -38,7 +38,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ pull: 'always', environment: { ANDROID_HOME: '/usr/lib/android-sdk' }, commands: [ - 'apt-get install -y ninja-build', + 'apt-get install -y ninja-build openjdk-17-jdk-headless', './gradlew testPlayDebugUnitTestCoverageReport' ], } @@ -78,7 +78,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ pull: 'always', environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' }, commands: [ - 'apt-get install -y ninja-build', + 'apt-get install -y ninja-build openjdk-17-jdk-headless', './gradlew assemblePlayDebug', './scripts/drone-static-upload.sh' ], diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 00000000000..e7f705722ff --- /dev/null +++ b/.drone.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index be928b39335..bf9ef62a972 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ project.properties .project .settings +.kotlin bin/ gen/ .idea/ diff --git a/app/build.gradle b/app/build.gradle index a1a6d43cedc..c46ec8d78f3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,18 @@ plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'org.jetbrains.kotlin.plugin.compose' id 'com.google.devtools.ksp' id 'com.google.dagger.hilt.android' + id 'kotlin-parcelize' + id 'kotlinx-serialization' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' apply plugin: 'witness' -apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlinx-serialization' -configurations.forEach { - it.exclude module: "commons-logging" +configurations.configureEach { + exclude module: "commons-logging" } def canonicalVersionCode = 380 @@ -40,12 +42,12 @@ android { useLibrary 'org.apache.http.legacy' compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } packagingOptions { @@ -54,6 +56,7 @@ android { } } + splits { abi { enable true @@ -64,7 +67,8 @@ android { } buildFeatures { - compose true + viewBinding true + buildConfig true } composeOptions { @@ -151,7 +155,7 @@ android { } } - applicationVariants.forEach { variant -> + applicationVariants.configureEach { variant -> variant.outputs.each { output -> def abiName = output.getFilter("ABI") ?: 'universal' def postFix = abiPostFix.get(abiName, 0) @@ -169,11 +173,11 @@ android { } } - buildFeatures { - viewBinding true - } - def huaweiEnabled = project.properties['huawei'] != null + lint { + abortOnError true + baseline file('lint-baseline.xml') + } applicationVariants.configureEach { variant -> if (variant.flavorName == 'huawei') { @@ -226,6 +230,7 @@ dependencies { ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion") ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") ksp("com.github.bumptech.glide:ksp:$glideVersion") + implementation("androidx.hilt:hilt-navigation-compose:$androidxHiltVersion") implementation("com.google.dagger:hilt-android:$daggerHiltVersion") implementation "androidx.appcompat:appcompat:$appcompatVersion" @@ -305,7 +310,6 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.phrase:phrase:$phraseVersion" implementation 'app.cash.copper:copper-flow:1.0.0' - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" @@ -329,7 +333,6 @@ dependencies { androidTestImplementation('com.adevinta.android:barista:4.2.0') { exclude group: 'org.jetbrains.kotlin' } - // AndroidJUnitRunner and JUnit Rules androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:rules:1.5.0' @@ -348,6 +351,8 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3" + debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3" androidTestUtil 'androidx.test:orchestrator:1.4.2' testImplementation 'org.robolectric:robolectric:4.12.2' @@ -365,6 +370,11 @@ dependencies { androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion" debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion" + // Navigation + implementation "androidx.navigation:navigation-fragment-ktx:$navVersion" + implementation "androidx.navigation:navigation-ui-ktx:$navVersion" + implementation "androidx.navigation:navigation-compose:$navVersion" + implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha" implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha" diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index 023120e1a8e..be464ad75a9 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -3,5 +3,8 @@ + + + \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt b/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt new file mode 100644 index 00000000000..c4b81d8148b --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt @@ -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)) + } + + +} \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt b/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt new file mode 100644 index 00000000000..e0799b0a3a0 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt @@ -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") + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 43b347ba42b..ddb999ab56d 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -1,12 +1,11 @@ package network.loki.messenger -import android.Manifest import android.app.Instrumentation import android.view.View +import android.content.ClipboardManager +import android.content.Context import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed @@ -16,14 +15,15 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest +import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry +import network.loki.messenger.util.sendMessage +import network.loki.messenger.util.waitFor import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.adevinta.android.barista.interaction.PermissionGranter import com.bumptech.glide.Glide import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable -import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not import org.junit.After @@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.home.HomeActivity */ @RunWith(AndroidJUnit4::class) -@LargeTest +@SmallTest class HomeActivityTests { @get:Rule @@ -108,6 +108,7 @@ class HomeActivityTests { onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) // new chat + Thread.sleep(500) onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard()) onView(withId(R.id.copyButton)).perform(ViewActions.click()) val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -147,11 +148,13 @@ class HomeActivityTests { setupLoggedInState() goToMyChat() TextSecurePreferences.setLinkPreviewsEnabled(context, true) - sendMessage("howdy") - sendMessage("test") - // tests url rewriter doesn't crash - sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest") - sendMessage("https://www.ámazon.com") + with (activityMonitor.waitForActivity() as ConversationActivityV2) { + sendMessage("howdy") + sendMessage("test") + // tests url rewriter doesn't crash + sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest") + sendMessage("https://www.ámazon.com") + } } @Test @@ -161,7 +164,9 @@ class HomeActivityTests { TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) // given the link url text val url = "https://www.ámazon.com" - sendMessage(url, LinkPreview(url, "amazon", Optional.absent())) + with (activityMonitor.waitForActivity() as ConversationActivityV2) { + sendMessage(url, LinkPreview(url, "amazon", Optional.absent())) + } // when the URL span is clicked onView(withSubstring(url)).perform(ViewActions.click()) @@ -175,21 +180,4 @@ class HomeActivityTests { onView(withText(dialogPromptText)).check(matches(isDisplayed())) }*/ - - /** - * Perform action of waiting for a specific time. - */ - fun waitFor(millis: Long): ViewAction { - return object : ViewAction { - override fun getConstraints(): Matcher? { - return isRoot() - } - - override fun getDescription(): String = "Wait for $millis milliseconds." - - override fun perform(uiController: UiController, view: View?) { - uiController.loopMainThreadForAtLeast(millis) - } - } - } } \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt index 54470569e19..39e9526aecd 100644 --- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -11,6 +11,10 @@ import network.loki.messenger.libsession_util.ConversationVolatileConfig import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.util.applySpiedStorage +import network.loki.messenger.util.maybeGetUserInfo +import network.loki.messenger.util.randomSeedBytes +import network.loki.messenger.util.randomSessionId import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.instanceOf import org.hamcrest.MatcherAssert.assertThat @@ -31,32 +35,15 @@ import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.crypto.KeyPairUtilities -import kotlin.random.Random @RunWith(AndroidJUnit4::class) @SmallTest class LibSessionTests { - private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray()) - private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey - private var fakeHashI = 0 private val nextFakeHash: String get() = "fakehash${fakeHashI++}" - private fun maybeGetUserInfo(): Pair? { - 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): ByteArray { val (key,_) = maybeGetUserInfo()!! val contacts = Contacts.newInstance(key) @@ -98,11 +85,10 @@ class LibSessionTests { @Test fun migration_one_to_ones() { - val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext - val storageSpy = spy(app.storage) - app.storage = storageSpy + val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storage = applicationContext.applySpiedStorage() - val newContactId = randomAccountId() + val newContactId = randomSessionId() val singleContact = Contact( id = newContactId, approved = true, @@ -111,10 +97,10 @@ class LibSessionTests { val newContactMerge = buildContactMessage(listOf(singleContact)) val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! fakePollNewConfig(contacts, newContactMerge) - verify(storageSpy).addLibSessionContacts(argThat { + verify(storage).addLibSessionContacts(argThat { first().let { it.id == newContactId && it.approved } && size == 1 }, any()) - verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) + verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) } @Test @@ -123,7 +109,7 @@ class LibSessionTests { val storageSpy = spy(app.storage) app.storage = storageSpy - val randomRecipient = randomAccountId() + val randomRecipient = randomSessionId() val newContact = Contact( id = randomRecipient, approved = true, @@ -158,7 +144,7 @@ class LibSessionTests { app.storage = storageSpy // Initial state - val randomRecipient = randomAccountId() + val randomRecipient = randomSessionId() val currentContact = Contact( id = randomRecipient, approved = true, diff --git a/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt b/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt new file mode 100644 index 00000000000..e7a3ce107cf --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt @@ -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(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? { + return ViewMatchers.isRoot() + } + + override fun getDescription(): String = "Wait for $millis milliseconds." + + override fun perform(uiController: UiController, view: View?) { + uiController.loopMainThreadForAtLeast(millis) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt b/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt new file mode 100644 index 00000000000..7b02efad68c --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt @@ -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? { + 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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eac4ef33001..9687c01143d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -154,7 +154,13 @@ android:label="@string/conversationsBlockedContacts" /> + + - + + parameters) { + Phrase builder = Phrase.from(this, stringRes); + for (Map.Entry entry : parameters.entrySet()) { + builder.put(entry.getKey(), entry.getValue()); + } + Toast.makeText(getApplicationContext(), builder.format(), toastLength).show(); + } + @Override public void onCreate() { TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX); @@ -222,9 +242,10 @@ public void onCreate() { storage, device, messageDataProvider, - ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), configFactory, - lastSentTimestampCache + lastSentTimestampCache, + this, + tokenFetcher ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); @@ -256,6 +277,8 @@ public void onCreate() { NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create(); HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); + pushRegistrationHandler.run(); + // add our shortcut debug menu if we are not in a release build if (BuildConfig.BUILD_TYPE != "release") { // add the config settings shortcut @@ -308,7 +331,8 @@ public void onStop(@NonNull LifecycleOwner owner) { if (poller != null) { poller.stopIfNeeded(); } - ClosedGroupPollerV2.getShared().stopAll(); + pollerFactory.stopAll(); + LegacyClosedGroupPollerV2.getShared().stopAll(); versionDataFetcher.stopTimedVersionCheck(); } @@ -316,6 +340,7 @@ public void onStop(@NonNull LifecycleOwner owner) { public void onTerminate() { stopKovenant(); // Loki OpenGroupManager.INSTANCE.stopPolling(); + pollerFactory.stopAll(); versionDataFetcher.stopTimedVersionCheck(); super.onTerminate(); } @@ -438,7 +463,7 @@ private void setUpPollingIfNeeded() { poller.setUserPublicKey(userPublicKey); return; } - poller = new Poller(configFactory, new Timer()); + poller = new Poller(configFactory); } public void startPollingIfNeeded() { @@ -446,7 +471,8 @@ public void startPollingIfNeeded() { if (poller != null) { poller.startIfNeeded(); } - ClosedGroupPollerV2.getShared().start(); + pollerFactory.startAll(); + LegacyClosedGroupPollerV2.getShared().start(); } public void retrieveUserProfile() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 4fd99dd6c07..e215ff462ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -405,6 +405,7 @@ private void forward() { @SuppressWarnings("CodeBlock2Expr") @SuppressLint("InlinedApi") private void saveToDisk() { + Log.w("ACL", "Asked to save to disk!"); MediaItem mediaItem = getCurrentMediaItem(); if (mediaItem == null) return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index 69d58411f36..9c6e23c3e06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -142,11 +142,11 @@ class SessionDialogBuilder(val context: Context) { fun dangerButton( @StringRes text: Int, - @StringRes contentDescription: Int = text, + @StringRes contentDescriptionRes: Int = text, listener: () -> Unit = {} ) = button( text, - contentDescription, + contentDescriptionRes, R.style.Widget_Session_Button_Dialog_DangerText, ) { listener() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 6445abed3b5..9ced2695df7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -215,13 +215,29 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) } } + override fun updateMessageAsDeleted(messageId: Long, isSms: Boolean): Long { + val messagingDatabase: MessagingDatabase = + if (isSms) DatabaseComponent.get(context).smsDatabase() + else DatabaseComponent.get(context).mmsDatabase() + + val isOutgoing = messagingDatabase.isOutgoing(messageId) + messagingDatabase.markAsDeleted(messageId) + + if (isOutgoing) { + messagingDatabase.deleteMessage(messageId) + } + + return messageId + } + override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = Address.fromSerialized(author) val message = database.getMessageFor(timestamp, address) ?: return null + updateMessageAsDeleted(message.id, !message.isMms) val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).smsDatabase() - messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention) + messagingDatabase.markAsDeleted(message.id) if (message.isOutgoing) { messagingDatabase.deleteMessage(message.id) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index 9511bddb6ab..b2092be85c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -51,19 +51,19 @@ class ProfilePictureView @JvmOverloads constructor( } fun update(recipient: Recipient) { - recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) } + recipient.run { update(address, isLegacyClosedGroupRecipient, isOpenGroupInboxRecipient) } } fun update( address: Address, - isClosedGroupRecipient: Boolean = false, + isLegacyClosedGroupRecipient: Boolean = false, isOpenGroupInboxRecipient: Boolean = false ) { fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName() ?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR) ?: publicKey - if (isClosedGroupRecipient) { + if (isLegacyClosedGroupRecipient) { val members = DatabaseComponent.get(context).groupDatabase() .getGroupMemberAddresses(address.toGroupString(), true) .sorted() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt index 7b92e505c6b..2e7c027b1e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt @@ -8,7 +8,6 @@ import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isGone -import androidx.core.widget.ImageViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt index 0db2ec89622..8b9dedb6bae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt @@ -52,7 +52,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St private fun getClosedGroups(contacts: List): List { return getItems(contacts, context.getString(R.string.conversationsGroups)) { - it.address.isClosedGroup + it.address.isLegacyClosedGroup || it.address.isClosedGroupV2 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 1c6442824d6..c9aca24f36c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -13,7 +13,6 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import com.google.android.material.tabs.TabLayoutMediator import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationActionBarBinding @@ -31,6 +30,8 @@ import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.database.Storage +import javax.inject.Inject @AndroidEntryPoint class ConversationActionBarView @JvmOverloads constructor( @@ -42,6 +43,7 @@ class ConversationActionBarView @JvmOverloads constructor( @Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var groupDb: GroupDatabase + @Inject lateinit var storage: Storage var delegate: ConversationActionBarDelegate? = null @@ -51,6 +53,9 @@ class ConversationActionBarView @JvmOverloads constructor( } } + val profilePictureView + get() = binding.profilePictureView + init { var previousState: Int var currentState = 0 @@ -80,7 +85,7 @@ class ConversationActionBarView @JvmOverloads constructor( ) { this.delegate = delegate binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( - if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size + if (recipient.isClosedGroupV2Recipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size ).let { LayoutParams(it, it) } update(recipient, openGroup, config) } @@ -141,7 +146,11 @@ class ConversationActionBarView @JvmOverloads constructor( val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 resources.getQuantityString(R.plurals.membersActive, userCount, userCount) } else { - val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size + val userCount = if (recipient.isClosedGroupV2Recipient) { + storage.getMembers(recipient.address.serialize()).size + } else { // legacy closed groups + groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size + } resources.getQuantityString(R.plurals.members, userCount, userCount) } settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index e086c959245..489d82a3905 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -5,6 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.MessageSender @@ -43,7 +44,11 @@ class DisappearingMessages @Inject constructor( messageExpirationManager.insertExpirationTimerMessage(message) MessageSender.send(message, address) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + if (address.isClosedGroupV2) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.from(address)) + } else { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } } fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { @@ -58,9 +63,9 @@ class DisappearingMessages @Inject constructor( dangerButton( text = if (message.expiresIn == 0L) R.string.confirm else R.string.set, - contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton + contentDescriptionRes = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton ) { - set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient) + set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupV2Recipient) } cancelButton() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt index 32e20b73d9a..36b0710f888 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -59,16 +59,28 @@ class DisappearingMessagesViewModel( init { viewModelScope.launch { val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE - val recipient = threadDb.getRecipientForThreadId(threadId) - val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } + val recipient = threadDb.getRecipientForThreadId(threadId) ?: return@launch + val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient } ?.run { groupDb.getGroup(address.toGroupString()).orNull() } + val isAdmin = when { + recipient.isClosedGroupV2Recipient -> { + // Handle the new closed group functionality + storage.getMembers(recipient.address.serialize()).any { it.sessionId == textSecurePreferences.getLocalNumber() && it.admin } + } + recipient.isLegacyClosedGroupRecipient -> { + // Handle as legacy group + groupRecord?.admins?.any{ it.serialize() == textSecurePreferences.getLocalNumber() } == true + } + else -> !recipient.isGroupRecipient + } + _state.update { it.copy( - address = recipient?.address, - isGroup = groupRecord != null, - isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(), - isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() }, + address = recipient.address, + isGroup = recipient.isGroupRecipient, + isNoteToSelf = recipient.address.serialize() == textSecurePreferences.getLocalNumber(), + isSelfAdmin = isAdmin, expiryMode = expiryMode, persistedMode = expiryMode ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt new file mode 100644 index 00000000000..49ce1267379 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt @@ -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 + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt new file mode 100644 index 00000000000..d55d0b2495a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt @@ -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() { + + 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 */ } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt new file mode 100644 index 00000000000..a44b3fc55d0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt @@ -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 + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt new file mode 100644 index 00000000000..a79d94b3ae4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt @@ -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() { + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt new file mode 100644 index 00000000000..17411b2211a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt @@ -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 create(modelClass: Class): T { + return ConversationSettingsViewModel(threadId, storage, prefs) as T + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt index 8dffb1fd9b7..f01eeaea2d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt @@ -66,7 +66,7 @@ class StartConversationFragment : BottomSheetDialogFragment(), StartConversation } override fun onCreateGroupSelected() { - replaceFragment(CreateGroupFragment().also { it.delegate = this }) + replaceFragment(CreateGroupFragment()) } override fun onJoinCommunitySelected() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt index bc298c5bd38..bae328c78ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt @@ -23,6 +23,7 @@ import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.border diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index 0a40c6ee391..2316c775cb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp @@ -142,8 +144,10 @@ private fun EnterAccountId( SessionOutlinedTextField( text = state.newMessageIdOrOns, modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing), - contentDescription = "Session id input box", + .padding(horizontal = LocalDimensions.current.spacing) + .semantics { + contentDescription = "Session id input box" + }, placeholder = stringResource(R.string.accountIdOrOnsEnter), onChange = callbacks::onChange, onContinue = callbacks::onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 51ca3bf6857..7f0d67ace4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -59,14 +59,23 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode @@ -80,7 +89,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -100,6 +108,7 @@ import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext @@ -111,6 +120,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity +import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityContract +import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityResult import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP @@ -147,7 +158,6 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord @@ -155,6 +165,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.home.search.getSearchName import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel @@ -175,7 +186,6 @@ import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme import org.thoughtcrime.securesms.util.ActivityDispatcher -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.NetworkUtils @@ -211,7 +221,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, ConversationActionBarDelegate, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, - ConversationMenuHelper.ConversationMenuListener { + ConversationMenuHelper.ConversationMenuListener, View.OnClickListener { private lateinit var binding: ActivityConversationV2Binding @@ -224,7 +234,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase - @Inject lateinit var storage: Storage + @Inject lateinit var storage: StorageProtocol @Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory @@ -236,6 +246,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + private val conversationSettingsCallback = registerForActivityResult(ConversationSettingsActivityContract()) { result -> + if (result is ConversationSettingsActivityResult.SearchConversation) { + // open search + binding?.toolbar?.menu?.findItem(R.id.menu_search)?.expandActionView() + } + } + private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val linkPreviewViewModel: LinkPreviewViewModel by lazy { ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) @@ -269,7 +286,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private val viewModel: ConversationViewModel by viewModels { - viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) + viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()) } private var actionMode: ActionMode? = null private var unreadCount = Int.MAX_VALUE @@ -404,6 +421,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe const val PICK_GIF = 10 const val PICK_FROM_LIBRARY = 12 const val INVITE_CONTACTS = 124 + const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result } // endregion @@ -478,11 +496,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe updatePlaceholder() setUpBlockedBanner() binding.searchBottomBar.setEventListener(this) + binding.toolbarContent.profilePictureView.setOnClickListener(this) updateSendAfterApprovalText() - setUpMessageRequestsBar() - - // Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the - // keyboard visible and have no need to immediately display it. + setUpMessageRequests() val weakActivity = WeakReference(this) @@ -506,6 +522,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpSearchResultObserver() scrollToFirstUnreadMessageIfNeeded() setUpOutdatedClientBanner() + setUpLegacyGroupBanner() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { binding.conversationRecyclerView.scrollToPosition(targetPosition) @@ -574,6 +591,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + override fun finish() { + super.finish() + } + override fun onPause() { super.onPause() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1) @@ -809,13 +830,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && legacyRecipient != null - binding.outdatedBanner.isVisible = shouldShowLegacy + binding.outdatedDisappearingBanner.isVisible = shouldShowLegacy if (shouldShowLegacy) { val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy) .put(NAME_KEY, legacyRecipient!!.name) .format() - binding?.outdatedBannerTextView?.text = txt + binding.outdatedBannerTextView.text = txt + } + } + + private fun setUpLegacyGroupBanner() { + val shouldDisplayBanner = viewModel.recipient?.isLegacyClosedGroupRecipient ?: return + + with(binding) { + outdatedGroupBanner.isVisible = shouldDisplayBanner + outdatedGroupBanner.setOnClickListener { + showSessionDialog { + title(R.string.urlOpenBrowser) + text(R.string.urlOpenDescription) + cancelButton() + dangerButton(R.string.open) { + // open the URL (tbc) + } + } + } } } @@ -840,21 +879,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun setUpUiStateObserver() { - lifecycleScope.launchWhenStarted { - viewModel.uiState.collect { uiState -> - uiState.uiMessages.firstOrNull()?.let { - Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show() - viewModel.messageShown(it.id) - } - if (uiState.isMessageRequestAccepted == true) { - binding.messageRequestBar.visibility = View.GONE - } - if (!uiState.conversationExists && !isFinishing) { - // Conversation should be deleted now, just go back + // Observe toast messages + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState + .mapNotNull { it.uiMessages.firstOrNull() } + .distinctUntilChanged() + .collect { msg -> + Toast.makeText(this@ConversationActivityV2, msg.message, Toast.LENGTH_LONG).show() + viewModel.messageShown(msg.id) + } + } + } + + // When we see "shouldExit", we finish the activity once for all. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // Wait for `shouldExit == true` then finish the activity + viewModel.uiState + .filter { it.shouldExit } + .first() + + if (!isFinishing) { finish() } } } + + // Observe the rest misc "simple" state change. They are bundled in one big + // state observing as these changes are relatively cheap to perform even redundantly. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + binding?.inputBar?.run { + isVisible = state.showInput + showMediaControls = state.enableInputMediaControls + } + } + } + } } private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int { @@ -914,11 +977,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (threadRecipient.isContactRecipient) { binding.blockedBanner.isVisible = threadRecipient.isBlocked } - setUpMessageRequestsBar() invalidateOptionsMenu() updateSendAfterApprovalText() - showOrHideInputIfNeeded() - maybeUpdateToolbar(threadRecipient) } } @@ -931,49 +991,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText } - private fun showOrHideInputIfNeeded() { - binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } - ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true } - ?: true - } - - private fun setUpMessageRequestsBar() { - binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread() - binding.messageRequestBar.isVisible = isIncomingMessageRequestThread() + private fun setUpMessageRequests() { binding.acceptMessageRequestButton.setOnClickListener { - acceptMessageRequest() + viewModel.acceptMessageRequest() } + binding.messageRequestBlock.setOnClickListener { block(deleteThread = true) } + binding.declineMessageRequestButton.setOnClickListener { viewModel.declineMessageRequest() - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) - } - finish() } - } - private fun acceptMessageRequest() { - binding.messageRequestBar.isVisible = false - viewModel.acceptMessageRequest() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState + .map { it.messageRequestState } + .distinctUntilChanged() + .collectLatest { state -> + binding.messageRequestBar.isVisible = state != MessageRequestUiState.Invisible - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) + if (state is MessageRequestUiState.Visible) { + binding.sendAcceptsTextView.setText(state.acceptButtonText) + binding.messageRequestBlock.isVisible = state.showBlockButton + binding.declineMessageRequestButton.setText(state.declineButtonText) + } + } + } } } - private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run { - !isGroupRecipient && !isLocalNumber && - !(hasApprovedMe() || viewModel.hasReceived()) - } ?: false - - private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run { - !isGroupRecipient && !isApproved && !isLocalNumber && - !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0 - } ?: false - override fun inputBarEditTextContentChanged(newContent: CharSequence) { val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead if (textSecurePreferences.isLinkPreviewsEnabled()) { @@ -1174,20 +1222,35 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } ?: false } + override fun onClick(v: View?) { + if (v === binding?.toolbarContent?.profilePictureView) { + // open conversation settings + conversationSettingsCallback.launch(viewModel.threadId) + } + } + override fun block(deleteThread: Boolean) { val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action") + val invitingAdmin = viewModel.invitingAdmin + + val name = if (recipient.isClosedGroupV2Recipient && invitingAdmin != null) { + invitingAdmin.getSearchName() + } else { + recipient.name + } + showSessionDialog { title(R.string.block) text( Phrase.from(context, R.string.blockDescription) - .put(NAME_KEY, recipient.name) + .put(NAME_KEY, name) .format() ) dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { viewModel.block() // Block confirmation toast added as per SS-64 - val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, recipient.name).format().toString() + val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, name).format().toString() Toast.makeText(context, txt, Toast.LENGTH_LONG).show() if (deleteThread) { @@ -1218,8 +1281,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } + // TODO: don't need to allow new closed group check here, removed in new disappearing messages override fun showDisappearingMessages(thread: Recipient) { - if (thread.isClosedGroupRecipient) { + if (thread.isLegacyClosedGroupRecipient) { groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return } } Intent(this, DisappearingMessagesActivity::class.java) @@ -1687,19 +1751,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) } - private fun processMessageRequestApproval() { - if (isIncomingMessageRequestThread()) { - acceptMessageRequest() - } else if (viewModel.recipient?.isApproved == false) { - // edge case for new outgoing thread on new recipient without sending approval messages - viewModel.setRecipientApproved() - } - } - private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset - processMessageRequestApproval() + viewModel.beforeSendingTextOnlyMessage() val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) @@ -1743,7 +1798,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ): Pair? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset - processMessageRequestApproval() + viewModel.beforeSendingAttachments() // Create the message val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp @@ -2088,7 +2143,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe cancelButton { endActionMode() } } // Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone - } else if (allSentByCurrentUser && allHasHash) { + } else if ((allSentByCurrentUser || viewModel.isClosedGroupAdmin) && allHasHash) { val bottomSheet = DeleteOptionsBottomSheet() bottomSheet.recipient = recipient bottomSheet.onDeleteForMeTapped = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b0a541a9e89..44c31b4ec95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,51 +1,60 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.goterl.lazysodium.utils.KeyPair import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.MmsDatabase +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.audio.AudioSlidePlayer -import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import java.util.UUID class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage, + private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, - database: MmsDatabase, + private val groupDb: GroupDatabase, + private val threadDb: ThreadDatabase, + private val appContext: Context, ) : ViewModel() { val showSendAfterApprovalText: Boolean get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false - private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) - val uiState: StateFlow = _uiState + private val _uiState = MutableStateFlow(ConversationUiState()) + val uiState: StateFlow get() = _uiState private var _recipient: RetrieveOnce = RetrieveOnce { repository.maybeGetRecipientForThreadId(threadId) @@ -65,12 +74,39 @@ class ConversationViewModel( } } + /** + * The admin who invites us to this group(v2) conversation. + * + * null if this convo is not a group(v2) conversation, or error getting the info + */ + val invitingAdmin: Recipient? + get() { + val recipient = recipient ?: return null + if (!recipient.isClosedGroupV2Recipient) return null + + return repository.getInvitingAdmin(threadId) + } + private var _openGroup: RetrieveOnce = RetrieveOnce { storage.getOpenGroup(threadId) } val openGroup: OpenGroup? get() = _openGroup.value + private val closedGroupMembers: List + 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 get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf() @@ -83,7 +119,7 @@ class ConversationViewModel( val isMessageRequestThread : Boolean get() { val recipient = recipient ?: return false - return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved + return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved } val canReactToMessages: Boolean @@ -97,16 +133,99 @@ class ConversationViewModel( ) init { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(Dispatchers.Default) { repository.recipientUpdateFlow(threadId) .collect { recipient -> - if (recipient == null && _uiState.value.conversationExists) { - _uiState.update { it.copy(conversationExists = false) } + _uiState.update { + it.copy( + shouldExit = recipient == null, + showInput = shouldShowInput(recipient), + enableInputMediaControls = shouldEnableInputMediaControls(recipient), + messageRequestState = buildMessageRequestState(recipient), + ) } } } } + /** + * Determines if the input media controls should be enabled. + * + * Normally we will show the input media controls, only in these situations we hide them: + * 1. First time we send message to a person. + * Since we haven't been approved by them, we can't send them any media, only text + */ + private fun shouldEnableInputMediaControls(recipient: Recipient?): Boolean { + if (recipient != null && + (recipient.is1on1 && !recipient.isLocalNumber) && + !recipient.hasApprovedMe()) { + return false + } + + return true + } + + /** + * Determines if the input bar should be shown. + * + * For these situations we hide the input bar: + * 1. The user has been kicked from a group(v2), OR + * 2. The legacy group is inactive, OR + * 3. The community chat is read only + */ + private fun shouldShowInput(recipient: Recipient?): Boolean { + return when { + recipient?.isClosedGroupV2Recipient == true -> !repository.isKicked(recipient) + recipient?.isLegacyClosedGroupRecipient == true -> { + groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true + } + openGroup != null -> openGroup?.canWrite == true + else -> true + } + } + + private fun buildMessageRequestState(recipient: Recipient?): MessageRequestUiState { + // The basic requirement of showing a message request is: + // 1. The other party has not been approved by us, AND + // 2. We haven't sent a message to them before (if we do, we would be the one requesting permission), AND + // 3. We have received message from them AND + // 4. The type of conversation supports message request (only 1to1 and groups v2) + + if ( + recipient != null && + + // Req 1: we haven't approved the other party + (!recipient.isApproved && !recipient.isLocalNumber) && + + // Req 4: the type of conversation supports message request + (recipient.is1on1 || recipient.isClosedGroupV2Recipient) && + + // Req 2: we haven't sent a message to them before + !threadDb.getLastSeenAndHasSent(threadId).second() && + + // Req 3: we have received message from them + threadDb.getMessageCount(threadId) > 0 + ) { + + return MessageRequestUiState.Visible( + acceptButtonText = if (recipient.isGroupRecipient) { + R.string.messageRequestGroupInviteDescription + } else { + R.string.messageRequestsAcceptDescription + }, + // You can block a 1to1 conversation, or a normal groups v2 conversation + showBlockButton = recipient.is1on1 || recipient.isClosedGroupV2Recipient, + declineButtonText = if (recipient.isClosedGroupV2Recipient) { + R.string.delete + } else { + R.string.decline + } + ) + } + + return MessageRequestUiState.Invisible + } + override fun onCleared() { super.onCleared() @@ -135,16 +254,17 @@ class ConversationViewModel( } fun block() { - val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action") - if (recipient.isContactRecipient) { - repository.setBlocked(recipient, true) + // inviting admin will be true if this request is a closed group message request + val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action") + if (recipient.isContactRecipient || recipient.isClosedGroupV2Recipient) { + repository.setBlocked(threadId, recipient, true) } } fun unblock() { val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") if (recipient.isContactRecipient) { - repository.setBlocked(recipient, false) + repository.setBlocked(threadId, recipient, false) } } @@ -167,11 +287,6 @@ class ConversationViewModel( AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop() } - fun setRecipientApproved() { - val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") - repository.setApproved(recipient, true) - } - fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") stopPlayingAudioMessage(message) @@ -221,19 +336,36 @@ class ConversationViewModel( fun acceptMessageRequest() = viewModelScope.launch { val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action") + val currentState = _uiState.value.messageRequestState as? MessageRequestUiState.Visible + ?: return@launch Log.w("Loki", "Current state was not visible for accept message request action") + + _uiState.update { + it.copy(messageRequestState = MessageRequestUiState.Pending(currentState)) + } + repository.acceptMessageRequest(threadId, recipient) .onSuccess { _uiState.update { - it.copy(isMessageRequestAccepted = true) + it.copy(messageRequestState = MessageRequestUiState.Invisible) + } + + withContext(Dispatchers.IO) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext) } } .onFailure { showMessage("Couldn't accept message request due to error: $it") + + _uiState.update { state -> + state.copy(messageRequestState = currentState) + } } } fun declineMessageRequest() { - repository.declineMessageRequest(threadId) + repository.declineMessageRequest(threadId, recipient!!) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext) + _uiState.update { it.copy(shouldExit = true) } } private fun showMessage(message: String) { @@ -278,6 +410,25 @@ class ConversationViewModel( attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) } + fun beforeSendingTextOnlyMessage() { + implicitlyApproveRecipient() + } + + fun beforeSendingAttachments() { + implicitlyApproveRecipient() + } + + private fun implicitlyApproveRecipient() { + val recipient = recipient + + if (uiState.value.messageRequestState is MessageRequestUiState.Visible) { + acceptMessageRequest() + } else if (recipient?.isApproved == false) { + // edge case for new outgoing thread on new recipient without sending approval messages + repository.setApproved(recipient, true) + } + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -288,9 +439,12 @@ class ConversationViewModel( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage, - private val mmsDatabase: MmsDatabase, + private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, + private val groupDb: GroupDatabase, + private val threadDb: ThreadDatabase, + @ApplicationContext + private val context: Context, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -300,7 +454,9 @@ class ConversationViewModel( repository = repository, storage = storage, messageDataProvider = messageDataProvider, - database = mmsDatabase + groupDb = groupDb, + threadDb = threadDb, + appContext = context, ) as T } } @@ -310,10 +466,24 @@ data class UiMessage(val id: Long, val message: String) data class ConversationUiState( val uiMessages: List = emptyList(), - val isMessageRequestAccepted: Boolean? = null, - val conversationExists: Boolean + val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible, + val shouldExit: Boolean = false, + val showInput: Boolean = true, + val enableInputMediaControls: Boolean = true, ) +sealed interface MessageRequestUiState { + data object Invisible : MessageRequestUiState + + data class Pending(val prevState: Visible) : MessageRequestUiState + + data class Visible( + @StringRes val acceptButtonText: Int, + val showBlockButton: Boolean, + @StringRes val declineButtonText: Int, + ) : MessageRequestUiState +} + data class RetrieveOnce(val retrieval: () -> T?) { private var triedToRetrieve: Boolean = false private var _value: T? = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt index 58c5536248a..404f0919229 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt @@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen if (!this::recipient.isInitialized) { return dismiss() } - if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { + if (recipient.isLocalNumber) { binding.deleteForEveryoneTextView.text = - resources.getString(R.string.clearMessagesForEveryone, contact) + getString(R.string.clearMessagesForMe) + } else if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { + binding.deleteForEveryoneTextView.text = + resources.getString(R.string.clearMessagesForEveryone) } - binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient + binding.deleteForEveryoneTextView.isVisible = !recipient.isLegacyClosedGroupRecipient binding.deleteForMeTextView.setOnClickListener(this) binding.deleteForEveryoneTextView.setOnClickListener(this) binding.cancelTextView.setOnClickListener(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index a830543ec15..a609f7f5b94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -54,6 +54,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity @@ -84,7 +85,7 @@ import javax.inject.Inject class MessageDetailActivity : PassphraseRequiredActionBarActivity() { @Inject - lateinit var storage: Storage + lateinit var storage: StorageProtocol private val viewModel: MessageDetailsViewModel by viewModels() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index d3e1de9912c..7ee19c53fde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -10,49 +10,58 @@ import androidx.fragment.app.DialogFragment import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload import javax.inject.Inject /** Shown when receiving media from a contact for the first time, to confirm that * they are to be trusted and files sent by them are to be downloaded. */ @AndroidEntryPoint -class DownloadDialog(private val recipient: Recipient) : DialogFragment() { +class AutoDownloadDialog(private val threadRecipient: Recipient, + private val databaseAttachment: DatabaseAttachment +) : DialogFragment() { + @Inject lateinit var storage: StorageProtocol @Inject lateinit var contactDB: SessionContactDatabase override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - val accountID = recipient.address.toString() - val contact = contactDB.getContactWithAccountID(accountID) - val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID + val threadId = storage.getThreadId(threadRecipient) ?: run { + dismiss() + return@createSessionDialog + } + val displayName = when { + threadRecipient.isCommunityRecipient -> storage.getOpenGroup(threadId)?.name ?: "UNKNOWN" + threadRecipient.isLegacyClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN" + threadRecipient.isClosedGroupV2Recipient -> threadRecipient.name ?: "UNKNOWN" + else -> storage.getContactWithAccountID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN" + } title(getString(R.string.attachmentsAutoDownloadModalTitle)) val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription) - .put(CONVERSATION_NAME_KEY, recipient.name) + .put(CONVERSATION_NAME_KEY, displayName) .format() val spannable = SpannableStringBuilder(explanation) - - val startIndex = explanation.indexOf(name) - spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val startIndex = explanation.indexOf(displayName) + spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) text(spannable) - button(R.string.download, R.string.AccessibilityId_download) { trust() } + button(R.string.download, R.string.AccessibilityId_download) { + setAutoDownload() + } + cancelButton { dismiss() } } - private fun trust() { - val accountID = recipient.address.toString() - val contact = contactDB.getContactWithAccountID(accountID) ?: return - val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient) - contactDB.setContactIsTrusted(contact, true, threadID) - JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) - dismiss() + private fun setAutoDownload() { + storage.setAutoDownloadAttachments(threadRecipient, true) + JobQueue.shared.createAndStartAttachmentDownload(databaseAttachment) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index cd911b2ace6..fe86f8d3827 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -60,8 +60,13 @@ class InputBar @JvmOverloads constructor( var delegate: InputBarDelegate? = null var quote: MessageRecord? = null var linkPreview: LinkPreview? = null - var showInput: Boolean = true - set(value) { field = value; showOrHideInputIfNeeded() } + private var showInput: Boolean = true + set(value) { + if (field != value) { + field = value + showOrHideInputIfNeeded() + } + } var showMediaControls: Boolean = true set(value) { field = value @@ -252,20 +257,20 @@ class InputBar @JvmOverloads constructor( } private fun showOrHideInputIfNeeded() { - if (showInput) { - setOf( binding.inputBarEditText, attachmentsButton ).forEach { it.isVisible = true } - microphoneButton.isVisible = text.isEmpty() - sendButton.isVisible = text.isNotEmpty() - } else { + if (!showInput) { cancelQuoteDraft() cancelLinkPreviewDraft() - val views = setOf( binding.inputBarEditText, attachmentsButton, microphoneButton, sendButton ) - views.forEach { it.isVisible = false } } + + binding.inputBarEditText.isVisible = showInput + attachmentsButton.isVisible = showInput + microphoneButton.isVisible = showInput && text.isEmpty() + sendButton.isVisible = showInput && text.isNotEmpty() } private fun showOrHideMediaControlsIfNeeded() { - setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls } + attachmentsButton.snIsEnabled = showMediaControls + microphoneButton.snIsEnabled = showMediaControls } fun addTextChangedListener(listener: (String) -> Unit) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index d4068a3e6c0..d4489dae3c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -85,10 +85,13 @@ class MentionViewModel( } val memberIDs = when { - recipient.isClosedGroupRecipient -> { + recipient.isLegacyClosedGroupRecipient -> { groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false) .map { it.serialize() } } + recipient.isClosedGroupV2Recipient -> { + storage.getMembers(recipient.address.serialize()).map { it.sessionId } + } recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20) recipient.isContactRecipient -> listOf(recipient.address.serialize()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 720310fa5eb..90335dd44e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -6,10 +6,10 @@ import android.view.Menu import android.view.MenuItem import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord @@ -37,7 +37,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! + val edKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!! val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 090bf47574d..3778eecc529 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -34,8 +34,8 @@ import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.groups.EditClosedGroupActivity -import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey +import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity +import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.showMuteDialog @@ -56,7 +56,7 @@ object ConversationMenuHelper { // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages - if (!isCommunity && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { + if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyClosedGroupRecipient || thread.isLocalNumber)) { inflater.inflate(R.menu.menu_conversation_expiration, menu) } // One-on-one chat menu allows copying the account id @@ -72,7 +72,7 @@ object ConversationMenuHelper { } } // Closed group menu (options that should only be present in closed groups) - if (thread.isClosedGroupRecipient) { + if (thread.isLegacyClosedGroupRecipient) { inflater.inflate(R.menu.menu_conversation_closed_group, menu) } // Open group menu @@ -258,15 +258,15 @@ object ConversationMenuHelper { } private fun editClosedGroup(context: Context, thread: Recipient) { - if (!thread.isClosedGroupRecipient) { return } - val intent = Intent(context, EditClosedGroupActivity::class.java) + if (!thread.isLegacyClosedGroupRecipient) { return } + val intent = Intent(context, EditLegacyGroupActivity::class.java) val groupID: String = thread.address.toGroupString() intent.putExtra(groupIDKey, groupID) context.startActivity(intent) } private fun leaveClosedGroup(context: Context, thread: Recipient) { - if (!thread.isClosedGroupRecipient) { return } + if (!thread.isLegacyClosedGroupRecipient) { return } val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() val admins = group.admins diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index ab8498acf4b..1c7e19ad958 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -16,6 +16,8 @@ import network.loki.messenger.databinding.ViewControlMessageBinding import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode @@ -45,6 +47,7 @@ class ControlMessageView : LinearLayout { binding.expirationTimerView.isGone = true binding.followSetting.isGone = true var messageBody: CharSequence = message.getDisplayBody(context) + binding.root.contentDescription = null binding.textView.text = messageBody when { @@ -54,7 +57,7 @@ class ControlMessageView : LinearLayout { val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) - if (threadRecipient?.isClosedGroupRecipient == true) { + if (threadRecipient?.isClosedGroupV2Recipient == true) { expirationTimerView.setTimerIcon() } else { expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) @@ -98,6 +101,12 @@ class ControlMessageView : LinearLayout { binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) } } + message.isGroupUpdateMessage -> { + val updateMessageData: UpdateMessageData? = UpdateMessageData.fromJSON(message.body) + if (updateMessageData?.isGroupErrorQuitKind() == true) { + binding.textView.setTextColor(context.getColorFromAttr(R.attr.danger)) + } + } } binding.textView.isGone = message.isCallLog diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt new file mode 100644 index 00000000000..72e37b5dddb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt deleted file mode 100644 index 7d1dc625f68..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt +++ /dev/null @@ -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)) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index d62cc532c44..4c274f5ed11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -23,6 +23,7 @@ import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr @@ -34,7 +35,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor @@ -61,7 +61,6 @@ class VisibleMessageContentView : ConstraintLayout { glide: RequestManager = Glide.with(this), thread: Recipient, searchQuery: String? = null, - contactIsTrusted: Boolean = true, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, suppressThumbnails: Boolean = false ) { @@ -71,8 +70,9 @@ class VisibleMessageContentView : ConstraintLayout { binding.contentParent.mainColor = color binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) - val onlyBodyMessage = message is SmsMessageRecord - val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null + val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE } + val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress } + val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null // reset visibilities / containers onContentClick.clear() @@ -85,7 +85,6 @@ class VisibleMessageContentView : ConstraintLayout { binding.bodyTextView.isVisible = false binding.quoteView.root.isVisible = false binding.linkPreviewView.root.isVisible = false - binding.untrustedView.root.isVisible = false binding.voiceMessageView.root.isVisible = false binding.documentView.root.isVisible = false binding.albumThumbnailView.root.isVisible = false @@ -100,9 +99,9 @@ class VisibleMessageContentView : ConstraintLayout { binding.bodyTextView.text = null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() - binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() - binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null - binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null + binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() + binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null + binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation @@ -140,6 +139,7 @@ class VisibleMessageContentView : ConstraintLayout { } when { + // LINK PREVIEW message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } @@ -147,10 +147,11 @@ class VisibleMessageContentView : ConstraintLayout { // When in a link preview ensure the bodyTextView can expand to the full width binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width } + // AUDIO message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { hideBody = true // Audio attachment - if (contactIsTrusted || message.isOutgoing) { + if (mediaDownloaded || mediaInProgress || message.isOutgoing) { binding.voiceMessageView.root.indexInAdapter = indexInAdapter binding.voiceMessageView.root.delegate = context as? ConversationActivityV2 binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) @@ -159,26 +160,38 @@ class VisibleMessageContentView : ConstraintLayout { onContentClick.add { binding.voiceMessageView.root.togglePlayback() } onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } } else { - // TODO: move this out to its own area - binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } + hideBody = true + (message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> + binding.pendingAttachmentView.root.bind( + PendingAttachmentView.AttachmentType.AUDIO, + getTextColor(context,message), + attachment + ) + onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } + } } } + // DOCUMENT message is MmsMessageRecord && message.slideDeck.documentSlide != null -> { - hideBody = true + hideBody = true // TODO: check if this is still the logic we want // Document attachment - if (contactIsTrusted || message.isOutgoing) { - binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) + if (mediaDownloaded || mediaInProgress || message.isOutgoing) { + binding.documentView.root.bind(message, getTextColor(context, message)) } else { - binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } + hideBody = true + (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> + binding.pendingAttachmentView.root.bind( + PendingAttachmentView.AttachmentType.DOCUMENT, + getTextColor(context,message), + attachment + ) + onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } + } } } + // IMAGE / VIDEO message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { - /* - * Images / Video attachment - */ - if (contactIsTrusted || message.isOutgoing) { + if (mediaDownloaded || mediaInProgress || message.isOutgoing) { // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // bind after add view because views are inflated and calculated during bind binding.albumThumbnailView.root.bind( @@ -196,13 +209,22 @@ class VisibleMessageContentView : ConstraintLayout { } else { hideBody = true binding.albumThumbnailView.root.clearViews() - binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) - onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } + val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment + firstAttachment?.let { attachment -> + binding.pendingAttachmentView.root.bind( + PendingAttachmentView.AttachmentType.IMAGE, + getTextColor(context,message), + attachment + ) + onContentClick.add { + binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) + } + } } } message.isOpenGroupInvitation -> { hideBody = true - binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) + binding.openGroupInvitationView.root.bind(message, getTextColor(context, message)) onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() } } } @@ -239,7 +261,7 @@ class VisibleMessageContentView : ConstraintLayout { fun recycle() { arrayOf( binding.deletedMessageView.root, - binding.untrustedView.root, + binding.pendingAttachmentView.root, binding.voiceMessageView.root, binding.openGroupInvitationView.root, binding.documentView.root, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 205f28a078d..3b74cbf4fb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -259,7 +259,6 @@ class VisibleMessageView : FrameLayout { glide, thread, searchQuery, - message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false), onAttachmentNeedsDownload ) binding.messageContentView.root.delegate = delegate diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java index 4db46a3abc4..e2fe41b6259 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java @@ -35,6 +35,12 @@ import java.io.IOException; +import kotlin.Unit; +import kotlinx.coroutines.channels.BufferOverflow; +import kotlinx.coroutines.flow.MutableSharedFlow; +import kotlinx.coroutines.flow.MutableStateFlow; +import kotlinx.coroutines.flow.SharedFlowKt; + /** * Utility class for working with identity keys. * @@ -56,6 +62,8 @@ public class IdentityKeyUtil { public static final String LOKI_SEED = "loki_seed"; public static final String HAS_MIGRATED_KEY = "has_migrated_keys"; + public static final MutableSharedFlow CHANGES = SharedFlowKt.MutableSharedFlow(0, 1, BufferOverflow.DROP_LATEST); + private static SharedPreferences getSharedPreferences(Context context) { return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0); } @@ -158,9 +166,11 @@ public static void save(Context context, String key, String value) { } if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences"); + CHANGES.tryEmit(Unit.INSTANCE); } public static void delete(Context context, String key) { context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0).edit().remove(key).commit(); + CHANGES.tryEmit(Unit.INSTANCE); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt index 19a511bfd6b..71aca6c948c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -4,6 +4,9 @@ import android.content.Context import androidx.core.content.contentValuesOf import androidx.core.database.getBlobOrNull import androidx.core.database.getLongOrNull +import androidx.sqlite.db.transaction +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { @@ -20,6 +23,11 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co "CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));" private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?" + private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?" + + val KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name + val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name + val MEMBER_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name } fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) { @@ -33,6 +41,49 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey)) } + fun deleteGroupConfigs(closedGroupId: AccountId) { + val db = writableDatabase + db.transaction { + val variants = arrayOf(KEYS_VARIANT, INFO_VARIANT, MEMBER_VARIANT) + db.delete(TABLE_NAME, VARIANT_IN_AND_PUBKEY_WHERE, + arrayOf(variants, closedGroupId.hexString) + ) + } + } + + fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) { + val db = writableDatabase + db.transaction { + val keyContent = contentValuesOf( + VARIANT to KEYS_VARIANT, + PUBKEY to publicKey, + DATA to keysConfig, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, keyContent, VARIANT_AND_PUBKEY_WHERE, + arrayOf(KEYS_VARIANT, publicKey) + ) + val infoContent = contentValuesOf( + VARIANT to INFO_VARIANT, + PUBKEY to publicKey, + DATA to infoConfig, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, infoContent, VARIANT_AND_PUBKEY_WHERE, + arrayOf(INFO_VARIANT, publicKey) + ) + val memberContent = contentValuesOf( + VARIANT to MEMBER_VARIANT, + PUBKEY to publicKey, + DATA to memberConfig, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, memberContent, VARIANT_AND_PUBKEY_WHERE, + arrayOf(MEMBER_VARIANT, publicKey) + ) + } + } + fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? { val db = readableDatabase val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt index 013bbf5cb52..6af3048e65b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt @@ -5,7 +5,7 @@ import android.content.Context import android.database.Cursor import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata -import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX +import org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -29,7 +29,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """ INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} - WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%' + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$LEGACY_CLOSED_GROUP_PREFIX%' AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) """.trimIndent() @@ -37,7 +37,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """ INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} - WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%' + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$LEGACY_CLOSED_GROUP_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%' AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%' AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 18dd42818d9..3fa1dc60934 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE +import org.session.libsession.database.ServerHashToMessageId import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -16,6 +17,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab private val messageHashTable = "loki_message_hash_database" private val smsHashTable = "loki_sms_hash_database" private val mmsHashTable = "loki_mms_hash_database" + const val groupInviteTable = "loki_group_invites" + + private val groupInviteDeleteTrigger = "group_invite_delete_trigger" + private val messageID = "message_id" private val serverID = "server_id" private val friendRequestStatus = "friend_request_status" @@ -23,6 +28,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab private val errorMessage = "error_message" private val messageType = "message_type" private val serverHash = "server_hash" + const val invitingSessionId = "inviting_session_id" + @JvmStatic val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);" @JvmStatic @@ -39,6 +46,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" @JvmStatic val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" + @JvmStatic + val createGroupInviteTableCommand = "CREATE TABLE IF NOT EXISTS $groupInviteTable ($threadID INTEGER PRIMARY KEY, $invitingSessionId STRING);" + @JvmStatic + val createThreadDeleteTrigger = "CREATE TRIGGER IF NOT EXISTS $groupInviteDeleteTrigger AFTER DELETE ON ${ThreadDatabase.TABLE_NAME} BEGIN DELETE FROM $groupInviteTable WHERE $threadID = OLD.${ThreadDatabase.ID}; END;" const val SMS_TYPE = 0 const val MMS_TYPE = 1 @@ -224,6 +235,49 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab } } + fun getSendersForHashes(threadId: Long, hashes: Set): List { + 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() + + smsCursor.use { cursor -> + while (cursor.moveToNext()) { + val hash = cursor.getString(1) + if (hash in hashes) { + serverHashToMessageIds += ServerHashToMessageId( + serverHash = hash, + isSms = true, + sender = cursor.getString(0), + messageId = cursor.getLong(2) + ) + } + } + } + + mmsCursor.use { cursor -> + while (cursor.moveToNext()) { + val hash = cursor.getString(1) + if (hash in hashes) { + serverHashToMessageIds += ServerHashToMessageId( + serverHash = hash, + isSms = false, + sender = cursor.getString(0), + messageId = cursor.getLong(2) + ) + } + } + } + + return serverHashToMessageIds + } + fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull { databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> cursor.getString(serverHash) @@ -255,6 +309,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab ) } + fun addGroupInviteReferrer(groupThreadId: Long, referrerSessionId: String) { + val contentValues = ContentValues(2).apply { + put(threadID, groupThreadId) + put(invitingSessionId, referrerSessionId) + } + databaseHelper.writableDatabase.insertOrUpdate( + groupInviteTable, contentValues, "$threadID = ?", arrayOf(groupThreadId.toString()) + ) + } + + fun groupInviteReferrer(groupThreadId: Long): String? { + return databaseHelper.readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) {cursor -> + cursor.getString(invitingSessionId) + } + } + + fun deleteGroupInviteReferrer(groupThreadId: Long) { + databaseHelper.writableDatabase.delete( + groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString()) + ) + } private fun getMessageTables(mms: Boolean) = sequenceOf( getMessageTable(mms), messageHashTable diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java index 63db0c66ba1..3f44588393c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -44,7 +44,8 @@ public class MediaDatabase extends Database { + MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", " - + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + " " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.LINK_PREVIEWS + " " + "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID @@ -52,7 +53,8 @@ public class MediaDatabase extends Database { + " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND " + AttachmentDatabase.DATA + " IS NOT NULL AND " + AttachmentDatabase.QUOTE + " = 0 AND " - + AttachmentDatabase.STICKER_PACK_ID + " IS NULL " + + AttachmentDatabase.STICKER_PACK_ID + " IS NULL AND " + + MmsDatabase.LINK_PREVIEWS + " IS NULL " + "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC"; private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index bc74496dda4..baa78abad1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -14,7 +14,6 @@ import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.util.SqlUtil; @@ -46,7 +45,7 @@ public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public abstract void markUnidentified(long messageId, boolean unidentified); - public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention); + public abstract void markAsDeleted(long messageId); public abstract boolean deleteMessage(long messageId); public abstract boolean deleteMessages(long[] messageId, long threadId); @@ -55,6 +54,8 @@ public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException; + public abstract String getTypeColumn(); + public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) { try { addToDocument(messageId, MISMATCHED_IDENTITIES, @@ -206,6 +207,19 @@ public void migrateThreadId(long oldThreadId, long newThreadId) { contentValues.put(THREAD_ID, newThreadId); db.update(getTableName(), contentValues, where, args); } + + public boolean isOutgoing(long messageId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + try(Cursor cursor = db.query(getTableName(), new String[]{getTypeColumn()}, + ID_WHERE, new String[]{String.valueOf(messageId)}, + null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + return MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(0)); + } + } + return false; + } + public static class SyncMessageId { private final Address address; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 5a2a9155de0..984c0507929 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -158,7 +158,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) get(context).groupReceiptDatabase() .update(ourAddress, id, status, timestamp) - get(context).threadDatabase().update(threadId, false, true) + get(context).threadDatabase().update(threadId, false) notifyConversationListeners(threadId) } } @@ -178,6 +178,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } + fun updateInfoMessage(messageId: Long, body: String?, runThreadUpdate: Boolean = true) { + val threadId = getThreadIdForMessage(messageId) + val db = databaseHelper.writableDatabase + db.execSQL( + "UPDATE $TABLE_NAME SET $BODY = ? WHERE $ID = ?", + arrayOf(body, messageId.toString()) + ) + with (get(context).threadDatabase()) { + setLastSeen(threadId) + setHasSent(threadId, true) + if (runThreadUpdate) { + update(threadId, true) + } + } + } + fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) { val db = databaseHelper.writableDatabase db.execSQL( @@ -257,7 +273,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa " WHERE " + ID + " = ?", arrayOf(id.toString() + "") ) if (threadId.isPresent) { - get(context).threadDatabase().update(threadId.get(), false, true) + get(context).threadDatabase().update(threadId.get(), false) } } @@ -304,7 +320,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) } - override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) { + override fun markAsDeleted(messageId: Long) { val database = databaseHelper.writableDatabase val contentValues = ContentValues() contentValues.put(READ, 1) @@ -626,7 +642,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { if (runThreadUpdate) { - get(context).threadDatabase().update(threadId, true, true) + get(context).threadDatabase().update(threadId, true) } } notifyConversationListeners(threadId) @@ -771,7 +787,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } setHasSent(threadId, true) if (runThreadUpdate) { - update(threadId, true, true) + update(threadId, true) } } return messageId @@ -851,23 +867,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } - private fun deleteQuotedFromMessages(toDeleteRecords: List) { - if (toDeleteRecords.isEmpty()) return - val queryBuilder = StringBuilder() - for (i in toDeleteRecords.indices) { - queryBuilder.append("$QUOTE_ID = ").append(toDeleteRecords[i].getId()) - if (i + 1 < toDeleteRecords.size) { - queryBuilder.append(" OR ") - } - } - val query = queryBuilder.toString() - val db = databaseHelper.writableDatabase - val values = ContentValues(2) - values.put(QUOTE_MISSING, 1) - values.put(QUOTE_AUTHOR, "") - db!!.update(TABLE_NAME, values, query, null) - } - /** * Delete all the messages in single queries where possible * @param messageIds a String array representation of regularly Long types representing message IDs @@ -900,6 +899,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa notifyStickerPackListeners() } + override fun getTypeColumn(): String = MESSAGE_BOX + // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?" // - it is "Was the thread deleted because removing that message resulted in an empty thread"! override fun deleteMessage(messageId: Long): Boolean { @@ -909,8 +910,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val groupReceiptDatabase = get(context).groupReceiptDatabase() groupReceiptDatabase.deleteRowsForMessage(messageId) val database = databaseHelper.writableDatabase - database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) - val threadDeleted = get(context).threadDatabase().update(threadId, false, true) + database.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) + val threadDeleted = get(context).threadDatabase().update(threadId, false) notifyConversationListeners(threadId) notifyStickerListeners() notifyStickerPackListeners() @@ -921,6 +922,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val argsArray = messageIds.map { "?" } val argValues = messageIds.map { it.toString() }.toTypedArray() + val attachmentDatabase = get(context).attachmentDatabase() + val groupReceiptDatabase = get(context).groupReceiptDatabase() + + queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }) + groupReceiptDatabase.deleteRowsForMessages(messageIds) + val db = databaseHelper.writableDatabase db.delete( TABLE_NAME, @@ -928,7 +935,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa argValues ) - val threadDeleted = get(context).threadDatabase().update(threadId, false, true) + val threadDeleted = get(context).threadDatabase().update(threadId, false) notifyConversationListeners(threadId) notifyStickerListeners() notifyStickerPackListeners() @@ -956,6 +963,62 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa deleteThreads(setOf(threadId)) } + fun deleteMediaFor(threadId: Long, fromUser: String? = null) { + val db = databaseHelper.writableDatabase + val whereString = + if (fromUser == null) "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL" + else "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL" + val whereArgs = if (fromUser == null) arrayOf(threadId.toString()) else arrayOf(threadId.toString(), fromUser) + var cursor: Cursor? = null + try { + cursor = db.query(TABLE_NAME, arrayOf(ID), whereString, whereArgs, null, null, null, null) + val toDeleteStringMessageIds = mutableListOf() + 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(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null) + val toDeleteStringMessageIds = mutableListOf() + while (cursor.moveToNext()) { + toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string + } + // TODO: this can probably be optimized out, + // currently attachmentDB uses MmsID not threadID which makes it difficult to delete + // and clean up on threadID alone + toDeleteStringMessageIds.toList().chunked(50).forEach { sublist -> + deleteMessages(sublist.toTypedArray()) + } + } finally { + cursor?.close() + } + val threadDb = get(context).threadDatabase() + threadDb.update(threadId, false) + notifyConversationListeners(threadId) + notifyStickerListeners() + notifyStickerPackListeners() + } + private fun getSerializedSharedContacts( insertedAttachmentIds: Map, contacts: List @@ -1099,7 +1162,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return false } - /*package*/ private fun deleteThreads(threadIds: Set) { val db = databaseHelper.writableDatabase val where = StringBuilder() @@ -1125,7 +1187,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } val threadDb = get(context).threadDatabase() for (threadId in threadIds) { - val threadDeleted = threadDb.update(threadId, false, true) + val threadDeleted = threadDb.update(threadId, false) notifyConversationListeners(threadId) } notifyStickerListeners() @@ -1133,17 +1195,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } /*package*/ - fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long) { + fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, onlyMedia: Boolean) { var cursor: Cursor? = null try { val db = databaseHelper.readableDatabase - var where = - THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") " - for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) { - where += " WHEN $outgoingType THEN $DATE_SENT < $date" - } - where += " ELSE $DATE_RECEIVED < $date END)" - cursor = db!!.query( + var where = "$THREAD_ID = ? AND $DATE_SENT < $date" + if (onlyMedia) where += " AND $PART_COUNT >= 1" + cursor = db.query( TABLE_NAME, arrayOf(ID), where, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index b737be855e2..f22f50ff573 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -37,7 +37,9 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.io.Closeable; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; import kotlin.Pair; @@ -261,6 +263,23 @@ public long getLastMessageID(long threadId) { } } + public List getUserMessages(long threadId, String sender) { + + List idList = new ArrayList<>(); + + try (Cursor cursor = getConversation(threadId, false)) { + Reader reader = readerFor(cursor); + while (reader.getNext() != null) { + MessageRecord record = reader.getCurrent(); + if (record.getIndividualRecipient().getAddress().serialize().equals(sender)) { + idList.add(record); + } + } + } + + return idList; + } + // Builds up and returns a list of all all the messages sent by this user in the given thread. // Used to do a pass through our local database to remove records when a user has "Ban & Delete" // called on them in a Community. diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index dcd7778c9a2..fb32fad978c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -65,13 +65,14 @@ public class RecipientDatabase extends Database { private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none private static final String WRAPPER_HASH = "wrapper_hash"; private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests"; + private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference private static final String[] RECIPIENT_PROJECTION = new String[] { BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, UNIDENTIFIED_ACCESS_MODE, - FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS + FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD, }; static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -110,6 +111,17 @@ public static String getCreateNotificationTypeCommand() { "ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;"; } + public static String getCreateAutoDownloadCommand() { + return "ALTER TABLE "+ TABLE_NAME + " " + + "ADD COLUMN " + AUTO_DOWNLOAD + " INTEGER DEFAULT -1;"; + } + + public static String getUpdateAutoDownloadValuesCommand() { + return "UPDATE "+TABLE_NAME+" SET "+AUTO_DOWNLOAD+" = 1 "+ + "WHERE "+ADDRESS+" IN (SELECT "+SessionContactDatabase.sessionContactTable+"."+SessionContactDatabase.accountID+" "+ + "FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))"; + } + public static String getCreateApprovedCommand() { return "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;"; @@ -184,31 +196,32 @@ public Optional getRecipientSettings(@NonNull Address address } Optional getRecipientSettings(@NonNull Cursor cursor) { - boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1; - boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; - boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; - String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); - String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); - int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE)); - int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); - int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); - long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); - int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE)); - String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR)); - int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID)); - int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES)); - int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED)); - String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY)); - String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME)); - String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI)); - String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL)); - String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI)); - String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME)); - String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR)); - boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; - String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); - int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); - boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; + boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1; + boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; + boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; + String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); + String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); + int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE)); + int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); + int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); + long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); + int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE)); + boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1; + String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR)); + int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID)); + int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES)); + int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED)); + String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY)); + String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME)); + String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI)); + String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL)); + String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI)); + String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME)); + String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR)); + boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; + String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); + int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); + boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH)); boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1; @@ -232,7 +245,7 @@ Optional getRecipientSettings(@NonNull Cursor cursor) { } return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil, - notifyType, + notifyType, autoDownloadAttachments, Recipient.DisappearingState.fromId(disappearingState), Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(callVibrateState), @@ -246,6 +259,22 @@ Optional getRecipientSettings(@NonNull Cursor cursor) { forceSmsSelection, wrapperHash, blocksCommunityMessageRequests)); } + public boolean isAutoDownloadFlagSet(Recipient recipient) { + SQLiteDatabase db = getReadableDatabase(); + Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().serialize() }, null, null, null); + boolean flagUnset = false; + try { + if (cursor.moveToFirst()) { + // flag isn't set if it is -1 + flagUnset = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == -1; + } + } finally { + cursor.close(); + } + // negate result (is flag set) + return !flagUnset; + } + public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { ContentValues values = new ContentValues(); values.put(COLOR, color.serialize()); @@ -321,6 +350,21 @@ public void setBlocked(@NonNull Iterable recipients, boolean blocked) notifyRecipientListeners(); } + public void setAutoDownloadAttachments(@NonNull Recipient recipient, boolean shouldAutoDownloadAttachments) { + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + try { + ContentValues values = new ContentValues(); + values.put(AUTO_DOWNLOAD, shouldAutoDownloadAttachments ? 1 : 0); + db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().serialize()}); + recipient.resolve().setAutoDownloadAttachments(shouldAutoDownloadAttachments); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + notifyRecipientListeners(); + } + public void setMuted(@NonNull Recipient recipient, long until) { ContentValues values = new ContentValues(); values.put(MUTE_UNTIL, until); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 27b3e73397c..d885225febf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -3,10 +3,9 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor -import androidx.core.database.getStringOrNull import org.json.JSONArray import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.utilities.AccountId +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -14,7 +13,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { companion object { - private const val sessionContactTable = "session_contact_database" + const val sessionContactTable = "session_contact_database" const val accountID = "session_id" const val name = "name" const val nickname = "nickname" @@ -83,23 +82,21 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it)) } contentValues.put(threadID, contact.threadID) - contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0) database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID )) notifyConversationListListeners() } fun contactFromCursor(cursor: Cursor): Contact { - val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID)) - val contact = Contact(accountID) - contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) - contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname)) - contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL)) - contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName)) - cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let { + val sessionID = cursor.getString(accountID) + val contact = Contact(sessionID) + contact.name = cursor.getStringOrNull(name) + contact.nickname = cursor.getStringOrNull(nickname) + contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL) + contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName) + cursor.getStringOrNull(profilePictureEncryptionKey)?.let { contact.profilePictureEncryptionKey = Base64.decode(it) } - contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID)) - contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0 + contact.threadID = cursor.getLong(threadID) return contact } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index e83c464c7df..bbc33740720 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -8,6 +8,7 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob +import org.session.libsession.messaging.jobs.InviteContactsJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageSendJob @@ -78,6 +79,13 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa return result.firstOrNull { job -> job.attachmentID == attachmentID } } + fun getGroupInviteJob(groupSessionId: String, memberSessionId: String): InviteContactsJob? { + val database = databaseHelper.readableDatabase + return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(InviteContactsJob.KEY)) { cursor -> + jobFromCursor(cursor) as? InviteContactsJob + }.firstOrNull { it != null && it.groupSessionId == groupSessionId && it.memberSessionIds.contains(memberSessionId) } + } + fun getMessageSendJob(messageSendJobID: String): MessageSendJob? { val database = databaseHelper.readableDatabase return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index f02498112fa..adf79fb2ed9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -158,7 +158,7 @@ private void updateTypeBitmask(long id, long maskOff, long maskOn) { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, false); notifyConversationListeners(threadId); } @@ -237,7 +237,7 @@ public void markUnidentified(long id, boolean unidentified) { } @Override - public void markAsDeleted(long messageId, boolean read, boolean hasMention) { + public void markAsDeleted(long messageId) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); ContentValues contentValues = new ContentValues(); contentValues.put(READ, 1); @@ -257,7 +257,7 @@ public void markExpireStarted(long id, long startedAtTimestamp) { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, false); notifyConversationListeners(threadId); } @@ -296,6 +296,11 @@ public boolean isOutgoingMessage(long timestamp) { return isOutgoing; } + @Override + public String getTypeColumn() { + return TYPE; + } + public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); Cursor cursor = null; @@ -320,7 +325,7 @@ public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryRecei ID + " = ?", new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); - DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, false); notifyConversationListeners(threadId); foundMessage = true; } @@ -403,7 +408,7 @@ private Pair updateMessageBodyAndType(long messageId, String body, l long threadId = getThreadIdForMessage(messageId); - DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true); notifyConversationListeners(threadId); notifyConversationListListeners(); @@ -478,7 +483,7 @@ protected Optional insertMessageInbox(IncomingTextMessage message, long messageId = db.insert(TABLE_NAME, null, values); if (runThreadUpdate) { - DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true); } if (message.getSubscriptionId() != -1) { @@ -570,7 +575,7 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, } if (runThreadUpdate) { - DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true); } long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first(); if (lastSeen < message.getSentTimestampMillis()) { @@ -630,7 +635,7 @@ public boolean deleteMessage(long messageId) { long threadId = getThreadIdForMessage(messageId); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); notifyConversationListeners(threadId); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false); + boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); return threadDeleted; } @@ -650,7 +655,7 @@ public boolean deleteMessages(long[] messageIds, long threadId) { ID + " IN (" + StringUtils.join(argsArray, ',') + ")", argValues ); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); + boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); notifyConversationListeners(threadId); return threadDeleted; } @@ -697,15 +702,14 @@ private boolean isDuplicate(OutgoingTextMessage message, long threadId) { } } - void deleteMessagesInThreadBeforeDate(long threadId, long date) { + void deleteMessagesFrom(long threadId, String fromUser) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); - String where = THREAD_ID + " = ? AND (CASE " + TYPE; - - for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) { - where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date; - } + db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser}); + } - where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)"); + void deleteMessagesInThreadBeforeDate(long threadId, long date) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String where = THREAD_ID + " = ? AND " + DATE_SENT + " < " + date; db.delete(TABLE_NAME, where, new String[] {threadId + ""}); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index a39598c55b7..693758bf270 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,31 +2,44 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import com.google.protobuf.ByteString +import com.goterl.lazysodium.utils.KeyPair +import network.loki.messenger.libsession_util.Config import java.security.MessageDigest -import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.GroupInfoConfig +import network.loki.messenger.libsession_util.GroupKeysConfig +import network.loki.messenger.libsession_util.GroupMembersConfig import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.util.BaseCommunityInfo -import network.loki.messenger.libsession_util.util.Contact as LibSessionContact import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupDisplayInfo import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.Sodium import network.loki.messenger.libsession_util.util.UserPic import network.loki.messenger.libsession_util.util.afterSend +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.bind +import nl.komponents.kovenant.functional.map import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth import org.session.libsession.messaging.BlindedIdMapping +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.ConfigurationSyncJob +import org.session.libsession.messaging.jobs.ConfigurationSyncJob.Companion.messageInformation 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.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveJob @@ -36,6 +49,7 @@ import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage +import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage import org.session.libsession.messaging.messages.signal.IncomingGroupMessage @@ -51,18 +65,25 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.snode.GroupSubAccountSwarmAuth import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.buildAuthenticatedDeleteBatchInfo +import org.session.libsession.snode.SnodeAPI.buildAuthenticatedStoreBatchInfo +import org.session.libsession.snode.SnodeMessage +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupRecord @@ -73,36 +94,56 @@ import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Co import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState +import org.session.libsession.utilities.withGroupConfigsOrNull import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.protos.SignalServiceProtos.DataMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeVerifier +import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.dependencies.PollerFactory import org.thoughtcrime.securesms.groups.ClosedGroupManager import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact +import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember private const val TAG = "Storage" open class Storage( context: Context, helper: SQLCipherOpenHelper, - val configFactory: ConfigFactory -) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener { + private val configFactory: ConfigFactory, + private val pollerFactory: PollerFactory, +) : Database(context, helper), StorageProtocol, + ThreadDatabase.ConversationThreadUpdateListener { override fun threadCreated(address: Address, threadId: Long) { val localUserAddress = getUserPublicKey() ?: return @@ -111,20 +152,31 @@ open class Storage( val volatile = configFactory.convoVolatile ?: return if (address.isGroup) { val groups = configFactory.userGroups ?: return - if (address.isClosedGroup) { - val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) - val closedGroup = getGroup(address.toGroupString()) - if (closedGroup != null && closedGroup.isActive) { - val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId) - groups.set(legacyGroup) - val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy( - lastRead = SnodeAPI.nowWithOffset, + when { + address.isLegacyClosedGroup -> { + val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) + val closedGroup = getGroup(address.toGroupString()) + if (closedGroup != null && closedGroup.isActive) { + val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId) + groups.set(legacyGroup) + val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy( + lastRead = SnodeAPI.nowWithOffset, + ) + volatile.set(newVolatileParams) + } + } + address.isClosedGroupV2 -> { + val AccountId = address.serialize() + groups.getClosedGroup(AccountId) ?: return Log.d("Closed group doesn't exist locally", NullPointerException()) + val conversation = Conversation.ClosedGroup( + AccountId, 0, false ) - volatile.set(newVolatileParams) + volatile.set(conversation) + } + address.isCommunity -> { + // these should be added on the group join / group info fetch + Log.w("Loki", "Thread created called for open group address, not adding any extra information") } - } else if (address.isCommunity) { - // these should be added on the group join / group info fetch - Log.w("Loki", "Thread created called for open group address, not adding any extra information") } } else if (address.isContact) { // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config @@ -149,13 +201,15 @@ open class Storage( val volatile = configFactory.convoVolatile ?: return if (address.isGroup) { val groups = configFactory.userGroups ?: return - if (address.isClosedGroup) { + if (address.isLegacyClosedGroup) { val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) volatile.eraseLegacyClosedGroup(accountId) groups.eraseLegacyGroup(accountId) } else if (address.isCommunity) { // these should be removed in the group leave / handling new configs Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere") + } else if (address.isClosedGroupV2) { + Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere") } } else { // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config @@ -182,6 +236,10 @@ open class Storage( return DatabaseComponent.get(context).lokiAPIDatabase().getUserX25519KeyPair() } + override fun getUserED25519KeyPair(): KeyPair? { + return KeyPairUtilities.getUserED25519KeyPair(context) + } + override fun getUserProfile(): Profile { val displayName = TextSecurePreferences.getProfileName(context) val profileKey = ProfileKeyUtil.getProfileKey(context) @@ -243,6 +301,42 @@ open class Storage( return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L } + override fun ensureMessageHashesAreSender( + hashes: Set, + sender: String, + closedGroupId: String + ): Boolean { + val dbComponent = DatabaseComponent.get(context) + val lokiMessageDatabase = dbComponent.lokiMessageDatabase() + val threadId = getThreadId(fromSerialized(closedGroupId))!! + val info = lokiMessageDatabase.getSendersForHashes(threadId, hashes) + return info.all { it.sender == sender } + } + + override fun deleteMessagesByHash(threadId: Long, hashes: List) { + val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider + val lokiMessageDatabase = DatabaseComponent.get(context).lokiMessageDatabase() + val info = lokiMessageDatabase.getSendersForHashes(threadId, hashes.toSet()) + // TODO: no idea if we need to server delete this + for ((serverHash, sender, messageIdToDelete, isSms) in info) { + messageDataProvider.updateMessageAsDeleted(messageIdToDelete, isSms) + if (!messageDataProvider.isOutgoingMessage(messageIdToDelete)) { + SSKEnvironment.shared.notificationManager.updateNotification(context) + } + } + } + override fun deleteMessagesByUser(threadId: Long, userSessionId: String) { + val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider + val userMessages = DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, userSessionId) + val (mmsMessages, smsMessages) = userMessages.partition { it.isMms } + if (mmsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(mmsMessages.map(MessageRecord::id), threadId, isSms = false) + } + if (smsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(smsMessages.map(MessageRecord::id), threadId, isSms = true) + } + } + override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { val threadDb = DatabaseComponent.get(context).threadDatabase() getRecipientForThread(threadId)?.let { recipient -> @@ -256,7 +350,8 @@ open class Storage( configFactory.convoVolatile?.let { config -> val convo = when { // recipient closed group - recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + recipient.isLegacyClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + recipient.isClosedGroupV2Recipient -> config.getOrConstructClosedGroup(recipient.address.serialize()) // recipient is open group recipient.isCommunityRecipient -> { val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return @@ -285,7 +380,7 @@ open class Storage( override fun updateThread(threadId: Long, unarchive: Boolean) { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.update(threadId, unarchive, false) + threadDb.update(threadId, unarchive) } override fun persist(message: VisibleMessage, @@ -302,6 +397,9 @@ open class Storage( ?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false val group: Optional = when { openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) + groupPublicKey != null && groupPublicKey.startsWith(IdPrefix.GROUP.value) -> { + Optional.of(SignalServiceGroup(Hex.fromStringCondensed(groupPublicKey), SignalServiceGroup.GroupType.SIGNAL)) + } groupPublicKey != null -> { val doubleEncoded = GroupUtil.doubleEncodeGroupID(groupPublicKey) Optional.of(SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(doubleEncoded), SignalServiceGroup.GroupType.SIGNAL)) @@ -314,7 +412,14 @@ open class Storage( val targetAddress = if ((isUserSender || isUserBlindedSender) && !message.syncTarget.isNullOrEmpty()) { fromSerialized(message.syncTarget!!) } else if (group.isPresent) { - fromSerialized(GroupUtil.getEncodedId(group.get())) + val idHex = group.get().groupId.toHexString() + if (idHex.startsWith(IdPrefix.GROUP.value)) { + fromSerialized(idHex) + } else { + fromSerialized(GroupUtil.getEncodedId(group.get())) + } + } else if (message.recipient?.startsWith(IdPrefix.GROUP.value) == true) { + fromSerialized(message.recipient!!) } else { senderAddress } @@ -442,7 +547,7 @@ open class Storage( return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) } - override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { + override fun notifyConfigUpdates(forConfigObject: Config, messageTimestamp: Long) { notifyUpdates(forConfigObject, messageTimestamp) } @@ -458,12 +563,15 @@ open class Storage( return configFactory.user?.getCommunityMessageRequests() == true } - private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { + private fun notifyUpdates(forConfigObject: Config, messageTimestamp: Long) { when (forConfigObject) { is UserProfile -> updateUser(forConfigObject, messageTimestamp) is Contacts -> updateContacts(forConfigObject, messageTimestamp) is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp) is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp) + is GroupInfoConfig -> updateGroupInfo(forConfigObject, messageTimestamp) + is GroupKeysConfig -> updateGroupKeys(forConfigObject) + is GroupMembersConfig -> updateGroupMembers(forConfigObject) } } @@ -487,7 +595,8 @@ open class Storage( if (userPic == UserPic.DEFAULT) { clearUserPic() } else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty() - && TextSecurePreferences.getProfilePictureURL(context) != userPic.url) { + && TextSecurePreferences.getProfilePictureURL(context) != userPic.url + ) { setUserProfilePicture(userPic.url, userPic.key) } @@ -508,11 +617,35 @@ open class Storage( // Set or reset the shared library to use latest expiration config getThreadId(recipient)?.let { setExpirationConfiguration( - getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp) + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: + ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp) ) } } + private fun updateGroupInfo(groupInfoConfig: GroupInfoConfig, messageTimestamp: Long) { + val threadId = getThreadId(fromSerialized(groupInfoConfig.id().hexString)) ?: return + val recipient = getRecipientForThread(threadId) ?: return + val db = DatabaseComponent.get(context).recipientDatabase() + db.setProfileName(recipient, groupInfoConfig.getName()) + groupInfoConfig.getDeleteBefore()?.let { removeBefore -> + trimThreadBefore(threadId, removeBefore) + } + groupInfoConfig.getDeleteAttachmentsBefore()?.let { removeAttachmentsBefore -> + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + mmsDb.deleteMessagesInThreadBeforeDate(threadId, removeAttachmentsBefore, onlyMedia = true) + } + // TODO: handle deleted group, handle delete attachment / message before a certain time + } + + private fun updateGroupKeys(groupKeys: GroupKeysConfig) { + // TODO: update something here? + } + + private fun updateGroupMembers(groupMembers: GroupMembersConfig) { + // TODO: maybe clear out some contacts or something? + } + private fun updateContacts(contacts: Contacts, messageTimestamp: Long) { val extracted = contacts.all().toList() addLibSessionContacts(extracted, messageTimestamp) @@ -542,6 +675,7 @@ open class Storage( is Conversation.OneToOne -> getThreadIdFor(conversation.accountId, null, null, createThread = false) is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false) is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) + is Conversation.ClosedGroup -> getThreadIdFor(conversation.accountId, null, null, createThread = false) // New groups will be managed bia libsession } if (threadId != null) { if (conversation.lastRead > getLastSeen(threadId)) { @@ -569,9 +703,9 @@ open class Storage( val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } } val existingJoinUrls = existingCommunities.values.map { it.joinURL } - val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup } + val existingLegacyClosedGroups = getAllGroups(includeInactive = true).filter { it.isLegacyClosedGroup } val lgcIds = lgc.map { it.accountId } - val toDeleteClosedGroups = existingClosedGroups.filter { group -> + val toDeleteClosedGroups = existingLegacyClosedGroups.filter { group -> GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds } @@ -603,9 +737,21 @@ open class Storage( } } + val newClosedGroups = userGroups.allClosedGroupInfo() + for (closedGroup in newClosedGroups) { + val recipient = Recipient.from(context, fromSerialized(closedGroup.groupAccountId.hexString), false) + setRecipientApprovedMe(recipient, true) + setRecipientApproved(recipient, !closedGroup.invited) + val threadId = getOrCreateThreadIdFor(recipient.address) + setPinned(threadId, closedGroup.priority == PRIORITY_PINNED) + if (!closedGroup.invited) { + pollerFactory.pollerFor(closedGroup.groupAccountId)?.start() + } + } + for (group in lgc) { val groupId = GroupUtil.doubleEncodeGroupID(group.accountId) - val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId } + val existingGroup = existingLegacyClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId } val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } if (existingGroup != null) { if (group.priority == PRIORITY_HIDDEN && existingThread != null) { @@ -617,12 +763,12 @@ open class Storage( threadDb.setPinned(existingThread, group.priority == PRIORITY_PINNED) } } else { - val members = group.members.keys.map { Address.fromSerialized(it) } - val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) } + val members = group.members.keys.map { fromSerialized(it) } + val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { fromSerialized(it) } val title = group.name val formationTimestamp = (group.joinedAt * 1000L) createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) - setProfileSharing(Address.fromSerialized(groupId), true) + setProfileSharing(fromSerialized(groupId), true) // Add the group to the user's set of public keys to poll for addClosedGroupPublicKey(group.accountId) // Store the encryption key pair @@ -631,7 +777,7 @@ open class Storage( // Notify the PN server PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey) // Notify the user - val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) + val threadID = getOrCreateThreadIdFor(fromSerialized(groupId)) threadDb.setDate(threadID, formationTimestamp) // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group, @@ -640,9 +786,9 @@ open class Storage( // Don't create config group here, it's from a config update // Start polling - ClosedGroupPollerV2.shared.startPolling(group.accountId) + LegacyClosedGroupPollerV2.shared.startPolling(group.accountId) } - getThreadId(Address.fromSerialized(groupId))?.let { + getThreadId(fromSerialized(groupId))?.let { setExpirationConfiguration( getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp) @@ -933,6 +1079,137 @@ open class Storage( DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) } + override fun createNewGroup(groupName: String, groupDescription: String, members: Set): Optional { + val userGroups = configFactory.userGroups ?: return Optional.absent() + val convoVolatile = configFactory.convoVolatile ?: return Optional.absent() + val ourSessionId = getUserPublicKey() ?: return Optional.absent() + + val groupCreationTimestamp = SnodeAPI.nowWithOffset + + val group = userGroups.createGroup() + val adminKey = checkNotNull(group.adminKey) { + "Admin key is null for new group creation." + } + + userGroups.set(group) + val groupInfo = configFactory.getGroupInfoConfig(group.groupAccountId) ?: return Optional.absent() + val groupMembers = configFactory.getGroupMemberConfig(group.groupAccountId) ?: return Optional.absent() + + with (groupInfo) { + setName(groupName) + setDescription(groupDescription) + } + + groupMembers.set( + LibSessionGroupMember(ourSessionId, getUserProfile().displayName, admin = true) + ) + + members.forEach { groupMembers.set(LibSessionGroupMember(it.accountID, it.name).setInvited()) } + + val groupKeys = configFactory.constructGroupKeysConfig(group.groupAccountId, + info = groupInfo, + members = groupMembers) ?: return Optional.absent() + + // Manually re-key to prevent issue with linked admin devices + groupKeys.rekey(groupInfo, groupMembers) + + val newGroupRecipient = group.groupAccountId.hexString + val configTtl = 14 * 24 * 60 * 60 * 1000L + // Test the sending + val keyPush = groupKeys.pendingConfig() ?: return Optional.absent() + + val groupAdminSigner = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey) + + val keysSnodeMessage = SnodeMessage( + newGroupRecipient, + Base64.encodeBytes(keyPush), + configTtl, + groupCreationTimestamp + ) + val keysBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo( + groupKeys.namespace(), + keysSnodeMessage, + groupAdminSigner + ) + + val (infoPush, infoSeqNo) = groupInfo.push() + val infoSnodeMessage = SnodeMessage( + newGroupRecipient, + Base64.encodeBytes(infoPush), + configTtl, + groupCreationTimestamp + ) + val infoBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo( + groupInfo.namespace(), + infoSnodeMessage, + groupAdminSigner + ) + + val (memberPush, memberSeqNo) = groupMembers.push() + val memberSnodeMessage = SnodeMessage( + newGroupRecipient, + Base64.encodeBytes(memberPush), + configTtl, + groupCreationTimestamp + ) + val memberBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo( + groupMembers.namespace(), + memberSnodeMessage, + groupAdminSigner + ) + + try { + val snode = SnodeAPI.getSingleTargetSnode(newGroupRecipient).get() + val response = SnodeAPI.getRawBatchResponse( + snode, + newGroupRecipient, + listOf(keysBatchInfo, infoBatchInfo, memberBatchInfo), + true + ).get() + + @Suppress("UNCHECKED_CAST") + val responseList = (response["results"] as List) + + val keyResponse = responseList[0] + val keyHash = (keyResponse["body"] as Map)["hash"] as String + val keyTimestamp = (keyResponse["body"] as Map)["t"] as Long + val infoResponse = responseList[1] + val infoHash = (infoResponse["body"] as Map)["hash"] as String + val memberResponse = responseList[2] + val memberHash = (memberResponse["body"] as Map)["hash"] as String + // TODO: check response success + groupKeys.loadKey(keyPush, keyHash, keyTimestamp, groupInfo, groupMembers) + groupInfo.confirmPushed(infoSeqNo, infoHash) + groupMembers.confirmPushed(memberSeqNo, memberHash) + + configFactory.saveGroupConfigs(groupKeys, groupInfo, groupMembers) // now check poller to be all + convoVolatile.set(Conversation.ClosedGroup(newGroupRecipient, groupCreationTimestamp, false)) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + val groupRecipient = Recipient.from(context, fromSerialized(newGroupRecipient), false) + SSKEnvironment.shared.profileManager.setName(context, groupRecipient, groupInfo.getName()) + setRecipientApprovedMe(groupRecipient, true) + setRecipientApproved(groupRecipient, true) + Log.d("Group Config", "Saved group config for $newGroupRecipient") + pollerFactory.updatePollers() + + val memberArray = members.map(Contact::accountID).toTypedArray() + val job = InviteContactsJob(group.groupAccountId.hexString, memberArray) + JobQueue.shared.add(job) + return Optional.of(groupRecipient) + } catch (e: Exception) { + Log.e("Group Config", e) + Log.e("Group Config", "Deleting group from our group") + // delete the group from user groups + userGroups.erase(group) + } finally { + groupKeys.free() + groupInfo.free() + groupMembers.free() + } + + return Optional.absent() + } + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) { val volatiles = configFactory.convoVolatile ?: return val userGroups = configFactory.userGroups ?: return @@ -1009,16 +1286,22 @@ open class Storage( DatabaseComponent.get(context).groupDatabase().updateZombieMembers(groupID, members) } - override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long) { + override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long): Long? { val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, 0, true, false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() - val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) + val infoMessage = IncomingGroupMessage(m, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() - smsDB.insertMessageInbox(infoMessage, true) + return smsDB.insertMessageInbox(infoMessage, true).orNull().messageId + } + + override fun updateInfoMessage(context: Context, messageId: Long, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection) { + val mmsDB = DatabaseComponent.get(context).mmsDatabase() + val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() + mmsDB.updateInfoMessage(messageId, updateData) } - override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) { + override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long): Long? { val userPublicKey = getUserPublicKey()!! val recipient = Recipient.from(context, fromSerialized(groupID), false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" @@ -1027,16 +1310,15 @@ open class Storage( val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) { Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") - return + return null } val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) mmsDB.markAsSent(infoMessageID, true) + return infoMessageID } - override fun isClosedGroup(publicKey: String): Boolean { - val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey) - val address = fromSerialized(publicKey) - return address.isClosedGroup || isClosedGroup + override fun isLegacyClosedGroup(publicKey: String): Boolean { + return DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey) } override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList { @@ -1073,6 +1355,10 @@ open class Storage( DatabaseComponent.get(context).lokiAPIDatabase().removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) } + override fun removeClosedGroupThread(threadID: Long) { + DatabaseComponent.get(context).threadDatabase().deleteConversation(threadID) + } + override fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) { DatabaseComponent.get(context).groupDatabase() .updateFormationTimestamp(groupID, formationTimestamp) @@ -1083,6 +1369,788 @@ open class Storage( .updateTimestampUpdated(groupID, updatedTimestamp) } + /** + * For new closed groups + */ + override fun getMembers(groupPublicKey: String): List = + configFactory.getGroupMemberConfig(AccountId(groupPublicKey))?.use { it.all() }?.toList() ?: emptyList() + + private fun approveGroupInvite(threadId: Long, groupSessionId: AccountId) { + val groups = configFactory.userGroups ?: return + val group = groups.getClosedGroup(groupSessionId.hexString) ?: return + + configFactory.persist( + forConfigObject = groups.apply { set(group.copy(invited = false)) }, + timestamp = SnodeAPI.nowWithOffset + ) + + // Send invite response if we aren't admin. If we already have admin access, + // the group configs are already up-to-date (hence no need to reponse to the invite) + if (group.adminKey == null) { + val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder() + .setIsApproved(true) + val responseData = GroupUpdateMessage.newBuilder() + .setInviteResponse(inviteResponse) + val responseMessage = GroupUpdated(responseData.build()) + clearMessages(threadId) + // this will fail the first couple of times :) + MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString)) + } else { + // Update our on member state + configFactory.getGroupMemberConfig(groupSessionId)?.use { members -> + configFactory.getGroupInfoConfig(groupSessionId)?.use { info -> + configFactory.getGroupKeysConfig(groupSessionId, info)?.use { keys -> + members.get(getUserPublicKey().orEmpty())?.let { member -> + members.set(member.setPromoteSuccess().setInvited()) + } + + configFactory.saveGroupConfigs(keys, info, members) + } + } + } + } + + configFactory.persist(groups, SnodeAPI.nowWithOffset) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + pollerFactory.pollerFor(groupSessionId)?.start() + + // clear any group invites for this session ID (just in case there's a re-invite from an approved member after an invite from non-approved) + DatabaseComponent.get(context).lokiMessageDatabase().deleteGroupInviteReferrer(threadId) + } + + override fun respondToClosedGroupInvitation( + threadId: Long, + groupRecipient: Recipient, + approved: Boolean + ) { + val groups = configFactory.userGroups ?: return + val groupSessionId = AccountId(groupRecipient.address.serialize()) + // Whether approved or not, delete the invite + DatabaseComponent.get(context).lokiMessageDatabase().deleteGroupInviteReferrer(threadId) + if (!approved) { + groups.eraseClosedGroup(groupSessionId.hexString) + configFactory.persist(groups, SnodeAPI.nowWithOffset) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + deleteConversation(threadId) + return + } else { + approveGroupInvite(threadId, groupSessionId) + } + + } + + override fun addClosedGroupInvite( + groupId: AccountId, + name: String, + authData: ByteArray?, + adminKey: ByteArray?, + invitingAdmin: AccountId, + invitingMessageHash: String?, + ) { + require(authData != null || adminKey != null) { + "Must provide either authData or adminKey" + } + + val recipient = Recipient.from(context, fromSerialized(groupId.hexString), false) + val profileManager = SSKEnvironment.shared.profileManager + val groups = configFactory.userGroups ?: return + val inviteDb = DatabaseComponent.get(context).lokiMessageDatabase() + val shouldAutoApprove = getRecipientApproved(fromSerialized(invitingAdmin.hexString)) + val closedGroupInfo = GroupInfo.ClosedGroupInfo( + groupAccountId = groupId, + adminKey = adminKey, + authData = authData, + priority = PRIORITY_VISIBLE, + invited = !shouldAutoApprove, + name = name, + ) + groups.set(closedGroupInfo) + + configFactory.persist(groups, SnodeAPI.nowWithOffset) + profileManager.setName(context, recipient, name) + val groupThreadId = getOrCreateThreadIdFor(recipient.address) + setRecipientApprovedMe(recipient, true) + setRecipientApproved(recipient, shouldAutoApprove) + if (shouldAutoApprove) { + approveGroupInvite(groupThreadId, groupId) + } else { + inviteDb.addGroupInviteReferrer(groupThreadId, invitingAdmin.hexString) + insertGroupInviteControlMessage(SnodeAPI.nowWithOffset, invitingAdmin.hexString, groupId, name) + } + + val userAuth = this.userAuth + if (invitingMessageHash != null && userAuth != null) { + val batch = SnodeAPI.buildAuthenticatedDeleteBatchInfo( + auth = userAuth, + listOf(invitingMessageHash) + ) + + SnodeAPI.getSingleTargetSnode(userAuth.accountId.hexString).map { snode -> + SnodeAPI.getRawBatchResponse(snode, userAuth.accountId.hexString, listOf(batch)) + }.success { + Log.d(TAG, "Successfully deleted invite message") + }.fail { e -> + Log.e(TAG, "Error deleting invite message", e) + } + } + } + + override fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: AccountId) { + // don't try to process invitee acceptance if we aren't admin + if (configFactory.userGroups?.getClosedGroup(closedGroup.hexString)?.hasAdminKey() != true) return + + configFactory.getGroupMemberConfig(closedGroup)?.use { groupMembers -> + val member = groupMembers.get(invitee) ?: run { + Log.e("ClosedGroup", "User wasn't in the group membership to add!") + return + } + if (!member.invitePending) return groupMembers.close() + if (approved) { + groupMembers.set(member.setAccepted()) + } else { + groupMembers.erase(member) + } + configFactory.persistGroupConfigDump(groupMembers, closedGroup, SnodeAPI.nowWithOffset) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.ClosedGroup(closedGroup.hexString)) + } + } + + override fun getLibSessionClosedGroup(groupSessionId: String): GroupInfo.ClosedGroupInfo? { + return configFactory.userGroups?.getClosedGroup(groupSessionId) + } + + override fun getClosedGroupDisplayInfo(groupSessionId: String): GroupDisplayInfo? { + val infoConfig = configFactory.getGroupInfoConfig(AccountId(groupSessionId)) ?: return null + val isAdmin = configFactory.userGroups?.getClosedGroup(groupSessionId)?.hasAdminKey() ?: return null + + return infoConfig.use { info -> + GroupDisplayInfo( + id = info.id(), + name = info.getName(), + profilePic = info.getProfilePic(), + expiryTimer = info.getExpiryTimer(), + destroyed = false, + created = info.getCreated(), + description = info.getDescription(), + isUserAdmin = isAdmin + ) + } + } + + override fun inviteClosedGroupMembers(groupSessionId: String, invitees: List) { + // don't try to process invitee acceptance if we aren't admin + if (configFactory.userGroups?.getClosedGroup(groupSessionId)?.hasAdminKey() != true) return + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return + val accountId = AccountId(groupSessionId) + val membersConfig = configFactory.getGroupMemberConfig(accountId) ?: return + val infoConfig = configFactory.getGroupInfoConfig(accountId) ?: return + val groupAuth = OwnedSwarmAuth.ofClosedGroup(accountId, adminKey) + + // Filter out people who aren't already invited + val filteredMembers = invitees.filter { + membersConfig.get(it) == null + } + // Create each member's contact info if we have it + filteredMembers.forEach { memberSessionId -> + val contact = getContactWithAccountID(memberSessionId) + val name = contact?.name + val url = contact?.profilePictureURL + val key = contact?.profilePictureEncryptionKey + val userPic = if (url != null && key != null) { + UserPic(url, key) + } else UserPic.DEFAULT + val member = membersConfig.getOrConstruct(memberSessionId).copy( + name = name, + profilePicture = userPic, + ).setInvited() + membersConfig.set(member) + } + + // Persist the config changes now, so we can show the invite status immediately + configFactory.persistGroupConfigDump(membersConfig, accountId, SnodeAPI.nowWithOffset) + + // re-key for new members + val keysConfig = configFactory.getGroupKeysConfig( + accountId, + info = infoConfig, + members = membersConfig, + free = false + ) ?: return + + keysConfig.rekey(infoConfig, membersConfig) + + // build unrevocation, in case of re-adding members + val membersToUnrevoke = filteredMembers.map { keysConfig.getSubAccountToken(AccountId(it)) } + val unrevocation = if (membersToUnrevoke.isNotEmpty()) { + SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = membersToUnrevoke + ) ?: return Log.e("ClosedGroup", "Failed to build revocation update") + } else { + null + } + + // Build and store the key update in group swarm + val toDelete = mutableListOf() + + val keyMessage = keysConfig.messageInformation(groupAuth) + val infoMessage = infoConfig.messageInformation(toDelete, groupAuth) + val membersMessage = membersConfig.messageInformation(toDelete, groupAuth) + + val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo( + auth = groupAuth, + messageHashes = toDelete, + ) + + val requests = buildList { + add(keyMessage.batch) + add(infoMessage.batch) + add(membersMessage.batch) + + if (unrevocation != null) { + add(unrevocation) + } + + add(delete) + } + + val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + groupSessionId, + requests, + sequence = true + ) + } + + try { + val rawResponse = response.get() + val results = (rawResponse["results"] as ArrayList).first() as Map + if (results["code"] as Int != 200) { + throw Exception("Response wasn't successful for unrevoke and key update: ${results["body"] as? String}") + } + + configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig) + + val job = InviteContactsJob(groupSessionId, filteredMembers.toTypedArray()) + JobQueue.shared.add(job) + + val timestamp = SnodeAPI.nowWithOffset + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), + adminKey + ) + val updatedMessage = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(filteredMembers) + .setType(GroupUpdateMemberChangeMessage.Type.ADDED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { this.sentTimestamp = timestamp } + MessageSender.send(updatedMessage, fromSerialized(groupSessionId)) + insertGroupInfoChange(updatedMessage, accountId) + infoConfig.free() + membersConfig.free() + keysConfig.free() + } catch (e: Exception) { + Log.e("ClosedGroup", "Failed to store new key", e) + infoConfig.free() + membersConfig.free() + keysConfig.free() + // toaster toast here + return + } + + } + + override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? { + val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset + val senderPublicKey = message.sender + val groupName = configFactory.getGroupInfoConfig(closedGroup)?.use { it.getName() }.orEmpty() + + val updateData = UpdateMessageData.buildGroupUpdate(message, groupName) ?: return null + + return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + } + + override fun insertGroupInfoLeaving(closedGroup: AccountId): Long? { + val sentTimestamp = SnodeAPI.nowWithOffset + val senderPublicKey = getUserPublicKey() ?: return null + val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupLeaving) + + return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + } + + override fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) { + val mmsDB = DatabaseComponent.get(context).mmsDatabase() + val newMessage = UpdateMessageData.buildGroupLeaveUpdate(newType) + mmsDB.updateInfoMessage(messageId, newMessage.toJSON()) + } + + private fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, closedGroup: AccountId, groupName: String): Long? { + val updateData = UpdateMessageData(UpdateMessageData.Kind.GroupInvitation(senderPublicKey, groupName)) + return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + } + + private fun insertUpdateControlMessage(updateData: UpdateMessageData, sentTimestamp: Long, senderPublicKey: String?, closedGroup: AccountId): Long? { + val userPublicKey = getUserPublicKey()!! + val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString), false) + val threadDb = DatabaseComponent.get(context).threadDatabase() + val threadID = threadDb.getThreadIdIfExistsFor(recipient) + val expirationConfig = getExpirationConfiguration(threadID) + val expiryMode = expirationConfig?.expiryMode + val expiresInMillis = expiryMode?.expiryMillis ?: 0 + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 + val inviteJson = updateData.toJSON() + + + if (senderPublicKey == null || senderPublicKey == userPublicKey) { + val infoMessage = OutgoingGroupMediaMessage( + recipient, + inviteJson, + closedGroup.hexString, + null, + sentTimestamp, + expiresInMillis, + expireStartedAt, + true, + null, + listOf(), + listOf() + ) + val mmsDB = DatabaseComponent.get(context).mmsDatabase() + val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() + // check for conflict here, not returning duplicate in case it's different + if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return null + val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) + mmsDB.markAsSent(infoMessageID, true) + return infoMessageID + } else { + val group = SignalServiceGroup(Hex.fromStringCondensed(closedGroup.hexString), SignalServiceGroup.GroupType.SIGNAL) + val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), expiresInMillis, expireStartedAt, true, false) + val infoMessage = IncomingGroupMessage(m, inviteJson, true) + val smsDB = DatabaseComponent.get(context).smsDatabase() + val insertResult = smsDB.insertMessageInbox(infoMessage, true) + return insertResult.orNull()?.messageId + } + } + + override fun promoteMember(groupAccountId: AccountId, promotions: List) { + val adminKey = configFactory.userGroups?.getClosedGroup(groupAccountId.hexString)?.adminKey ?: return + if (adminKey.isEmpty()) { + return Log.e("ClosedGroup", "No admin key for group") + } + + configFactory.withGroupConfigsOrNull(groupAccountId) { info, members, keys -> + promotions.forEach { accountId -> + val promoted = members.get(accountId.hexString)?.setPromoteSent() ?: return@forEach + members.set(promoted) + + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setPromoteMessage( + DataMessage.GroupUpdatePromoteMessage.newBuilder() + .setGroupIdentitySeed(ByteString.copyFrom(adminKey)) + .setName(info.getName()) + ) + .build() + ) + MessageSender.send(message, fromSerialized(accountId.hexString)) + } + + configFactory.saveGroupConfigs(keys, info, members) + } + + + val groupDestination = Destination.ClosedGroup(groupAccountId.hexString) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) + val timestamp = SnodeAPI.nowWithOffset + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), + adminKey + ) + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(promotions.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { + sentTimestamp = timestamp + } + + MessageSender.send(message, fromSerialized(groupDestination.publicKey)) + insertGroupInfoChange(message, groupAccountId) + } + + private suspend fun doRemoveMember( + groupSessionId: AccountId, + removedMembers: List, + sendRemovedMessage: Boolean, + removeMemberMessages: Boolean, + ) { + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId.hexString)?.adminKey + if (adminKey == null || adminKey.isEmpty()) { + return Log.e("ClosedGroup", "No admin key for group") + } + + val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupSessionId, adminKey) + + configFactory.withGroupConfigsOrNull(groupSessionId) { info, members, keys -> + // To remove a member from a group, we need to first: + // 1. Notify the swarm that this member's key has bene revoked + // 2. Send a "kicked" message to a special namespace that the kicked member can still read + // 3. Optionally, send "delete member messages" to the group. (So that every device in the group + // delete this member's messages locally.) + // These three steps will be included in a sequential call as they all need to be done in order. + // After these steps are all done, we will do the following: + // Update the group configs to remove the member, sync if needed, then + // delete the member's messages locally and remotely. + val messageSendTimestamp = SnodeAPI.nowWithOffset + + val essentialRequests = buildList { + this += SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = removedMembers.map(keys::getSubAccountToken) + ) + + this += Sodium.encryptForMultipleSimple( + messages = removedMembers.map{"${it.hexString}-${keys.currentGeneration()}".encodeToByteArray()}.toTypedArray(), + recipients = removedMembers.map { it.pubKeyBytes }.toTypedArray(), + ed25519SecretKey = adminKey, + domain = Sodium.KICKED_DOMAIN + ).let { encryptedForMembers -> + buildAuthenticatedStoreBatchInfo( + namespace = Namespace.REVOKED_GROUP_MESSAGES(), + message = SnodeMessage( + recipient = groupSessionId.hexString, + data = Base64.encodeBytes(encryptedForMembers), + ttl = SnodeMessage.CONFIG_TTL, + timestamp = messageSendTimestamp + ), + auth = groupAuth + ) + } + + if (removeMemberMessages) { + val adminSignature = + SodiumUtilities.sign(buildDeleteMemberContentSignature( + memberIds = removedMembers, + messageHashes = emptyList(), + timestamp = messageSendTimestamp + ), adminKey) + + this += buildAuthenticatedStoreBatchInfo( + namespace = Namespace.CLOSED_GROUP_MESSAGES(), + message = MessageSender.buildWrappedMessageToSnode( + destination = Destination.ClosedGroup(groupSessionId.hexString), + message = GroupUpdated(GroupUpdateMessage.newBuilder() + .setDeleteMemberContent( + GroupUpdateDeleteMemberContentMessage.newBuilder() + .addAllMemberSessionIds(removedMembers.map { it.hexString }) + .setAdminSignature(ByteString.copyFrom(adminSignature)) + ) + .build() + ).apply { sentTimestamp = messageSendTimestamp }, + isSyncMessage = false + ), + auth = groupAuth + ) + } + } + + val snode = SnodeAPI.getSingleTargetSnode(groupSessionId.hexString).await() + val responses = SnodeAPI.getBatchResponse(snode, groupSessionId.hexString, essentialRequests, sequence = true) + + require(responses.results.all { it.code == 200 }) { + "Failed to execute essential steps for removing member" + } + + // Next step: update group configs, rekey, remove member messages if required + val messagesToDelete = mutableListOf() + for (member in removedMembers) { + members.erase(member.hexString) + } + + keys.rekey(info, members) + + if (removeMemberMessages) { + val threadId = getThreadId(fromSerialized(groupSessionId.hexString)) + if (threadId != null) { + val component = DatabaseComponent.get(context) + val mmsSmsDatabase = component.mmsSmsDatabase() + val lokiDb = component.lokiMessageDatabase() + for (member in removedMembers) { + for (msg in mmsSmsDatabase.getUserMessages(threadId, member.hexString)) { + val serverHash = lokiDb.getMessageServerHash(msg.id, msg.isMms) + if (serverHash != null) { + messagesToDelete.add(serverHash) + } + } + + deleteMessagesByUser(threadId, member.hexString) + } + } + } + + val requests = buildList { + this += "Sync keys config messages" to keys.messageInformation(groupAuth).batch + this += "Sync info config messages" to info.messageInformation(messagesToDelete, groupAuth).batch + this += "Sync member config messages" to members.messageInformation(messagesToDelete, groupAuth).batch + this += "Delete outdated config and member messages" to buildAuthenticatedDeleteBatchInfo(groupAuth, messagesToDelete) + } + + val response = SnodeAPI.getBatchResponse( + snode = snode, + publicKey = groupSessionId.hexString, + requests = requests.map { it.second } + ) + + if (responses.results.any { it.code != 200 }) { + val errors = responses.results.mapIndexedNotNull { index, item -> + if (item.code != 200) { + requests[index].first + } else { + null + } + } + + Log.e(TAG, "Failed to execute some steps for removing member: $errors") + } + + // Persist the changes + configFactory.saveGroupConfigs(keys, info, members) + + if (sendRemovedMessage) { + val timestamp = messageSendTimestamp + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp), + adminKey + ) + + val updateMessage = GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(removedMembers.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + val message = GroupUpdated( + updateMessage + ).apply { sentTimestamp = timestamp } + MessageSender.send(message, Destination.ClosedGroup(groupSessionId.hexString), false) + insertGroupInfoChange(message, groupSessionId) + } + } + + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded( + Destination.ClosedGroup(groupSessionId.hexString) + ) + } + + override suspend fun removeMember( + groupAccountId: AccountId, + removedMembers: List, + removeMessages: Boolean + ) { + doRemoveMember( + groupAccountId, + removedMembers, + sendRemovedMessage = true, + removeMemberMessages = removeMessages + ) + } + + override suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) { + val userGroups = configFactory.userGroups ?: return + val closedGroupHexString = closedGroupId.hexString + val closedGroup = userGroups.getClosedGroup(closedGroupId.hexString) ?: return + if (closedGroup.hasAdminKey()) { + // re-key and do a new config removing the previous member + doRemoveMember( + closedGroupId, + listOf(AccountId(message.sender!!)), + sendRemovedMessage = false, + removeMemberMessages = false + ) + } else { + configFactory.getGroupMemberConfig(closedGroupId)?.use { memberConfig -> + // if the leaving member is an admin, disable the group and remove it + // This is just to emulate the "existing" group behaviour, this will need to be removed in future + if (memberConfig.get(message.sender!!)?.admin == true) { + pollerFactory.pollerFor(closedGroupId)?.stop() + getThreadId(fromSerialized(closedGroupHexString))?.let { threadId -> + deleteConversation(threadId) + } + configFactory.removeGroup(closedGroupId) + } + } + } + } + + override fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId) { + insertGroupInfoChange(message, closedGroupId) + } + + override fun handleKicked(groupAccountId: AccountId) { + pollerFactory.pollerFor(groupAccountId)?.stop() + } + + override fun leaveGroup(groupSessionId: String, deleteOnLeave: Boolean): Boolean { + val closedGroupId = AccountId(groupSessionId) + val canSendGroupMessage = configFactory.userGroups?.getClosedGroup(groupSessionId)?.kicked != true + + try { + if (canSendGroupMessage) { + // throws on unsuccessful send + MessageSender.sendNonDurably( + message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) + .build() + ), + address = fromSerialized(groupSessionId), + isSyncMessage = false + ).get() + + MessageSender.sendNonDurably( + message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) + .build() + ), + address = fromSerialized(groupSessionId), + isSyncMessage = false + ).get() + } + + pollerFactory.pollerFor(closedGroupId)?.stop() + // TODO: set "deleted" and post to -10 group namespace? + if (deleteOnLeave) { + getThreadId(fromSerialized(groupSessionId))?.let { threadId -> + deleteConversation(threadId) + } + configFactory.removeGroup(closedGroupId) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + } catch (e: Exception) { + Log.e("ClosedGroup", "Failed to send leave group message", e) + return false + } + return true + } + + override fun setName(groupSessionId: String, newName: String) { + val closedGroupId = AccountId(groupSessionId) + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return + if (adminKey.isEmpty()) { + return Log.e("ClosedGroup", "No admin key for group") + } + + configFactory.withGroupConfigsOrNull(closedGroupId) { info, members, keys -> + info.setName(newName) + configFactory.saveGroupConfigs(keys, info, members) + } + + val groupDestination = Destination.ClosedGroup(groupSessionId) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) + val timestamp = SnodeAPI.nowWithOffset + val signature = SodiumUtilities.sign( + buildInfoChangeVerifier(GroupUpdateInfoChangeMessage.Type.NAME, timestamp), + adminKey + ) + + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setInfoChangeMessage( + GroupUpdateInfoChangeMessage.newBuilder() + .setUpdatedName(newName) + .setType(GroupUpdateInfoChangeMessage.Type.NAME) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { + sentTimestamp = timestamp + } + MessageSender.send(message, fromSerialized(groupSessionId)) + insertGroupInfoChange(message, closedGroupId) + } + + override fun sendGroupUpdateDeleteMessage(groupSessionId: String, messageHashes: List): Promise { + val closedGroup = configFactory.userGroups?.getClosedGroup(groupSessionId) + ?: return Promise.ofFail(NullPointerException("No group found")) + + val keys = configFactory.getGroupKeysConfig(AccountId(groupSessionId)) + ?: return Promise.ofFail(NullPointerException("No group keys found")) + + val adminKey = if (closedGroup.hasAdminKey()) closedGroup.adminKey else null + val authData = closedGroup.authData + val auth = if (adminKey != null) { + OwnedSwarmAuth.ofClosedGroup(AccountId(groupSessionId), adminKey) + } else if (authData != null) { + GroupSubAccountSwarmAuth(keys, AccountId(groupSessionId), authData) + } else { + return Promise.ofFail(IllegalStateException("No auth data nor admin key found")) + } + + val groupDestination = Destination.ClosedGroup(groupSessionId) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) + val timestamp = SnodeAPI.nowWithOffset + val signature = adminKey?.let { key -> + SodiumUtilities.sign( + buildDeleteMemberContentSignature(memberIds = emptyList(), messageHashes, timestamp), + key + ) + } + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setDeleteMemberContent( + GroupUpdateDeleteMemberContentMessage.newBuilder() + .addAllMessageHashes(messageHashes) + .let { + if (signature != null) it.setAdminSignature(ByteString.copyFrom(signature)) + else it + } + ) + .build() + ).apply { + sentTimestamp = timestamp + } + + // Delete might need fake hash? + val authenticatedDelete = if (adminKey == null) null else buildAuthenticatedDeleteBatchInfo(auth, messageHashes, required = true) + val authenticatedStore = buildAuthenticatedStoreBatchInfo( + namespace = Namespace.CLOSED_GROUP_MESSAGES(), + message = MessageSender.buildWrappedMessageToSnode(Destination.ClosedGroup(groupSessionId), message, false), + auth = auth + ) + + keys.free() + + // delete only present when admin + val storeIndex = if (adminKey != null) 1 else 0 + return SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + groupSessionId, + listOfNotNull(authenticatedDelete, authenticatedStore), + sequence = true + ) + }.map { rawResponse -> + val results = (rawResponse["results"] as ArrayList)[storeIndex] as Map + val hash = results["hash"] as? String + message.serverHash = hash + MessageSender.handleSuccessfulMessageSend(message, groupDestination, false) + } + } + override fun setServerCapabilities(server: String, capabilities: List) { return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) } @@ -1144,10 +2212,14 @@ open class Storage( return if (!openGroupID.isNullOrEmpty()) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } - } else if (!groupPublicKey.isNullOrEmpty()) { + } else if (!groupPublicKey.isNullOrEmpty() && !groupPublicKey.startsWith(IdPrefix.GROUP.value)) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) if (createThread) database.getOrCreateThreadIdFor(recipient) else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } + } else if (!groupPublicKey.isNullOrEmpty()) { + val recipient = Recipient.from(context, fromSerialized(groupPublicKey), false) + if (createThread) database.getOrCreateThreadIdFor(recipient) + else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } else { val recipient = Recipient.from(context, fromSerialized(publicKey), false) if (createThread) database.getOrCreateThreadIdFor(recipient) @@ -1208,6 +2280,10 @@ open class Storage( return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull() } + override fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean { + return DatabaseComponent.get(context).recipientDatabase().isAutoDownloadFlagSet(recipient) + } + override fun addLibSessionContacts(contacts: List, timestamp: Long) { val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val moreContacts = contacts.filter { contact -> @@ -1301,6 +2377,18 @@ open class Storage( } } + override fun shouldAutoDownloadAttachments(recipient: Recipient): Boolean { + return recipient.autoDownloadAttachments + } + + override fun setAutoDownloadAttachments( + recipient: Recipient, + shouldAutoDownloadAttachments: Boolean + ) { + val recipientDb = DatabaseComponent.get(context).recipientDatabase() + recipientDb.setAutoDownloadAttachments(recipient, shouldAutoDownloadAttachments) + } + override fun setRecipientHash(recipient: Recipient, recipientHash: String?) { val recipientDb = DatabaseComponent.get(context).recipientDatabase() recipientDb.setRecipientHash(recipient, recipientHash) @@ -1340,19 +2428,28 @@ open class Storage( } } else if (threadRecipient.isGroupRecipient) { val groups = configFactory.userGroups ?: return - if (threadRecipient.isClosedGroupRecipient) { - threadRecipient.address.serialize() - .let(GroupUtil::doubleDecodeGroupId) - .let(groups::getOrConstructLegacyGroupInfo) - .copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) - .let(groups::set) - } else if (threadRecipient.isCommunityRecipient) { - val openGroup = getOpenGroup(threadID) ?: return - val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return - val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( - priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE - ) - groups.set(newGroupInfo) + when { + threadRecipient.isLegacyClosedGroupRecipient -> { + threadRecipient.address.serialize() + .let(GroupUtil::doubleDecodeGroupId) + .let(groups::getOrConstructLegacyGroupInfo) + .copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) + .let(groups::set) + } + threadRecipient.isClosedGroupV2Recipient -> { + val newGroupInfo = groups.getOrConstructClosedGroup(threadRecipient.address.serialize()).copy ( + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } + threadRecipient.isCommunityRecipient -> { + val openGroup = getOpenGroup(threadID) ?: return + val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return + val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } } } ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) @@ -1405,6 +2502,29 @@ open class Storage( } } + override fun clearMessages(threadID: Long, fromUser: Address?): Boolean { + val smsDb = DatabaseComponent.get(context).smsDatabase() + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + val threadDb = DatabaseComponent.get(context).threadDatabase() + if (fromUser == null) { + // this deletes all *from* thread, not deleting the actual thread + smsDb.deleteThread(threadID) + mmsDb.deleteThread(threadID) // threadDB update called from within + } else { + // this deletes all *from* thread, not deleting the actual thread + smsDb.deleteMessagesFrom(threadID, fromUser.serialize()) + mmsDb.deleteMessagesFrom(threadID, fromUser.serialize()) + threadDb.update(threadID, false) + } + return true + } + + override fun clearMedia(threadID: Long, fromUser: Address?): Boolean { + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + mmsDb.deleteMediaFor(threadID, fromUser?.serialize()) + return true + } + override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { return PartAuthority.getAttachmentDataUri(attachmentId) } @@ -1520,6 +2640,12 @@ open class Storage( } setRecipientApproved(sender, true) setRecipientApprovedMe(sender, true) + + // Also update the config about this contact + configFactory.contacts?.upsertContact(sender.address.serialize()) { + approved = true + approvedMe = true + } val message = IncomingMediaMessage( sender.address, response.sentTimestamp!!, @@ -1543,7 +2669,7 @@ open class Storage( } override fun getRecipientApproved(address: Address): Boolean { - return DatabaseComponent.get(context).recipientDatabase().getApproved(address) + return address.isClosedGroupV2 || DatabaseComponent.get(context).recipientDatabase().getApproved(address) } override fun setRecipientApproved(recipient: Recipient, approved: Boolean) { @@ -1713,7 +2839,7 @@ open class Storage( override fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? { val recipient = getRecipientForThread(threadId) ?: return null - val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) ?: return null + val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) return when { recipient.isLocalNumber -> configFactory.user?.getNtsExpiry() recipient.isContactRecipient -> { @@ -1721,14 +2847,24 @@ open class Storage( recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) } ?.let { configFactory.contacts?.get(it)?.expiryMode } } - recipient.isClosedGroupRecipient -> { + recipient.isClosedGroupV2Recipient -> { + configFactory.getGroupInfoConfig(AccountId(recipient.address.serialize()))?.getExpiryTimer()?.let { + if (it == 0L) ExpiryMode.NONE else ExpiryMode.AfterSend(it) + } + } + recipient.isLegacyClosedGroupRecipient -> { // read it from group config if exists GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) .let { configFactory.userGroups?.getLegacyGroupInfo(it) } ?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE } } else -> null - }?.let { ExpirationConfiguration(threadId, it, dbExpirationMetadata.updatedTimestampMs) } + }?.let { ExpirationConfiguration( + threadId, + it, + // This will be 0L for new closed groups, apparently we don't need this anymore? + dbExpirationMetadata?.updatedTimestampMs ?: 0L + ) } } override fun setExpirationConfiguration(config: ExpirationConfiguration) { @@ -1744,12 +2880,17 @@ open class Storage( DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(recipient.address.serialize(), null) } - if (recipient.isClosedGroupRecipient) { + if (recipient.isLegacyClosedGroupRecipient) { val userGroups = configFactory.userGroups ?: return val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address) val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey) ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return userGroups.set(groupInfo) + } else if (recipient.isClosedGroupV2Recipient) { + val groupSessionId = AccountId(recipient.address.serialize()) + val groupInfo = configFactory.getGroupInfoConfig(groupSessionId) ?: return + groupInfo.setExpiryTimer(expiryMode.expirySeconds) + configFactory.persist(groupInfo, SnodeAPI.nowWithOffset, groupSessionId.hexString) } else if (recipient.isLocalNumber) { val user = configFactory.user ?: return user.setNtsExpiry(expiryMode) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index f48686aded7..bd38a59a514 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -17,7 +17,7 @@ */ package org.thoughtcrime.securesms.database; -import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX; +import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; @@ -124,10 +124,15 @@ public interface ConversationThreadUpdateListener { .map(columnName -> TABLE_NAME + "." + columnName) .toList(); - private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION), - Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)), - Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)) - .toList(); + private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = + // wew + Stream.concat(Stream.concat(Stream.concat( + Stream.of(TYPED_THREAD_PROJECTION), + Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)), + Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)), + Stream.of(LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId) + ) + .toList(); public static String getCreatePinnedCommand() { return "ALTER TABLE "+ TABLE_NAME + " " + @@ -279,9 +284,9 @@ public void trimThread(long threadId, int length) { Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); - DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); + DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate, false); - update(threadId, false, true); + update(threadId, false); notifyConversationListeners(threadId); } } finally { @@ -293,8 +298,8 @@ public void trimThread(long threadId, int length) { public void trimThreadBefore(long threadId, long timestamp) { Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); - DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); - update(threadId, false, true); + DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp, false); + update(threadId, false); notifyConversationListeners(threadId); } @@ -428,32 +433,6 @@ public Cursor getRecentConversationList(int limit) { return db.rawQuery(query, null); } - public int getUnapprovedConversationCount() { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - - try { - String query = "SELECT COUNT (*) FROM " + TABLE_NAME + - " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + - " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + - " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + - RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + - RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + - GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; - cursor = db.rawQuery(query, null); - - if (cursor != null && cursor.moveToFirst()) - return cursor.getInt(0); - } finally { - if (cursor != null) - cursor.close(); - } - - return 0; - } - public long getLatestUnapprovedConversationTimestamp() { SQLiteDatabase db = databaseHelper.getReadableDatabase(); Cursor cursor = null; @@ -492,13 +471,15 @@ public Cursor getBlindedConversationList() { } public Cursor getApprovedConversationList() { - String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " + + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+ LEGACY_CLOSED_GROUP_PREFIX +"%') " + + "OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " + "AND " + ARCHIVED + " = 0 "; return getConversationList(where); } public Cursor getUnapprovedConversationList() { - String where = MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + + String where = "("+MESSAGE_COUNT + " != 0 OR "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" LIKE '"+IdPrefix.GROUP.getValue()+"%')" + + " AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; @@ -722,19 +703,14 @@ public void setHasSent(long threadId, boolean hasSent) { notifyConversationListListeners(); } - public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) { + public boolean update(long threadId, boolean unarchive) { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); long count = mmsSmsDatabase.getConversationCount(threadId); - boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId); - - if (count == 0 && shouldDeleteEmptyThread) { - deleteThread(threadId); - notifyConversationListListeners(); - return true; - } + MmsSmsDatabase.Reader reader = null; - try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) { + try { + reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId)); MessageRecord record = null; if (reader != null) { record = reader.getNext(); @@ -748,11 +724,7 @@ record = reader.getNext(); record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); return false; } else { - if (shouldDeleteEmptyThread) { - deleteThread(threadId); - return true; - } - // todo: add empty snippet that clears existing data + updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0); return false; } } finally { @@ -800,9 +772,8 @@ public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastS return setLastSeen(threadId, lastSeenTime); } - private boolean possibleToDeleteThreadOnEmpty(long threadId) { - Recipient threadRecipient = getRecipientForThreadId(threadId); - return threadRecipient != null && !threadRecipient.isCommunityRecipient(); + private boolean deleteThreadOnEmpty(long threadId) { + return false; } private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { @@ -840,12 +811,14 @@ private boolean possibleToDeleteThreadOnEmpty(long threadId) { String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); String query = "SELECT " + projection + " FROM " + TABLE_NAME + - " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + - " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + - " WHERE " + where + - " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC"; + " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME + + " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + + " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + + " LEFT OUTER JOIN " + LokiMessageDatabase.groupInviteTable + + " ON "+ TABLE_NAME + "." + ID + " = " + LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId + + " WHERE " + where + + " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC"; if (limit > 0) { query += " LIMIT " + limit; @@ -923,6 +896,7 @@ public ThreadRecord getCurrent() { long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); Uri snippetUri = getSnippetUri(cursor); boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0; + String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId)); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -940,7 +914,7 @@ public ThreadRecord getCurrent() { return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count, unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type, - distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned); + distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned, invitingAdmin); } private @Nullable Uri getSnippetUri(Cursor cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index b6ebd6db84e..0a08ccd3571 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -90,9 +90,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV44 = 65; private static final int lokiV45 = 66; private static final int lokiV46 = 67; + private static final int lokiV47 = 68; + private static final int lokiV48 = 69; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV46; + private static final int DATABASE_VERSION = lokiV48; private static final int MIN_DATABASE_VERSION = lokiV7; private static final String CIPHER3_DATABASE_NAME = "signal.db"; public static final String DATABASE_NAME = "signal_v4.db"; @@ -362,6 +364,11 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); + + db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand()); + db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand()); + db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger()); } @Override @@ -628,6 +635,16 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); } + if (oldVersion < lokiV47) { + db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand()); + db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand()); + } + + if (oldVersion < lokiV48) { + db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index a61b78b4b6e..67382e98513 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -118,7 +118,7 @@ public boolean isUpdate() { public SpannableString getDisplayBody(@NonNull Context context) { if (isGroupUpdateMessage()) { UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody()); - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); + return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing(), true)); } else if (isExpirationTimerUpdate()) { int seconds = (int) (getExpiresIn() / 1000); boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 96cd5e88813..ae11c1c0dfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -30,6 +30,9 @@ import android.text.style.StyleSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import org.session.libsession.messaging.utilities.UpdateMessageBuilder; +import org.session.libsession.messaging.utilities.UpdateMessageData; import com.squareup.phrase.Phrase; import org.session.libsession.utilities.ExpirationUtil; import org.session.libsession.utilities.recipients.Recipient; @@ -46,39 +49,41 @@ */ public class ThreadRecord extends DisplayRecord { - private @Nullable final Uri snippetUri; - public @Nullable final MessageRecord lastMessage; - private final long count; - private final int unreadCount; - private final int unreadMentionCount; - private final int distributionType; - private final boolean archived; - private final long expiresIn; - private final long lastSeen; - private final boolean pinned; - private final int initialRecipientHash; - private final long dateSent; - - public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, - @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, - int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, - long snippetType, int distributionType, boolean archived, long expiresIn, - long lastSeen, int readReceiptCount, boolean pinned) - { - super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); - this.snippetUri = snippetUri; - this.lastMessage = lastMessage; - this.count = count; - this.unreadCount = unreadCount; - this.unreadMentionCount = unreadMentionCount; - this.distributionType = distributionType; - this.archived = archived; - this.expiresIn = expiresIn; - this.lastSeen = lastSeen; - this.pinned = pinned; - this.initialRecipientHash = recipient.hashCode(); - this.dateSent = date; - } + private @Nullable final Uri snippetUri; + public @Nullable final MessageRecord lastMessage; + private final long count; + private final int unreadCount; + private final int unreadMentionCount; + private final int distributionType; + private final boolean archived; + private final long expiresIn; + private final long lastSeen; + private final boolean pinned; + private final int initialRecipientHash; + private final String invitingAdminId; + private final long dateSent; + + public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, + @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, + int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, + long snippetType, int distributionType, boolean archived, long expiresIn, + long lastSeen, int readReceiptCount, boolean pinned, String invitingAdminId) + { + super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); + this.snippetUri = snippetUri; + this.lastMessage = lastMessage; + this.count = count; + this.unreadCount = unreadCount; + this.unreadMentionCount = unreadMentionCount; + this.distributionType = distributionType; + this.archived = archived; + this.expiresIn = expiresIn; + this.lastSeen = lastSeen; + this.pinned = pinned; + this.initialRecipientHash = recipient.hashCode(); + this.invitingAdminId = invitingAdminId; + this.dateSent = date; + } public @Nullable Uri getSnippetUri() { return snippetUri; @@ -115,6 +120,18 @@ private String getDisappearingMsgExpiryTypeString(Context context) { @Override public SpannableString getDisplayBody(@NonNull Context context) { if (isGroupUpdateMessage()) { + String body = getBody(); + if (!body.isEmpty()) { + UpdateMessageData updateMessageData = UpdateMessageData.fromJSON(body); + if (updateMessageData != null) { + return emphasisAdded( + UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false) + .toString() + ); + } else { + return null; + } + } return emphasisAdded(context.getString(R.string.groupUpdated)); } else if (isOpenGroupInvitation()) { return emphasisAdded(context.getString(R.string.communityInvitation)); @@ -221,4 +238,30 @@ private SpannableString emphasisAdded(String sequence, int start, int end) { public boolean isPinned() { return pinned; } public int getInitialRecipientHash() { return initialRecipientHash; } + + public boolean isLeavingGroup() { + if (isGroupUpdateMessage()) { + String body = getBody(); + if (!body.isEmpty()) { + UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body); + return updateMessageData.isGroupLeavingKind(); + } + } + return false; + } + + public boolean isErrorLeavingGroup() { + if (isGroupUpdateMessage()) { + String body = getBody(); + if (!body.isEmpty()) { + UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body); + return updateMessageData.isGroupErrorQuitKind(); + } + } + return false; + } + + public String getInvitingAdminId() { + return invitingAdminId; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 750b3e20c7e..7cfa1a6f9d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -76,7 +76,7 @@ class DebugMenuViewModel @Inject constructor( // clear remote and local data, then restart the app viewModelScope.launch { try { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get() + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application) } catch (e: Exception) { // we can ignore fails here as we might be switching environments before the user gets a public key } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index a9a72e76657..ea81223a337 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -1,14 +1,20 @@ package org.thoughtcrime.securesms.dependencies +import android.content.Context +import android.widget.Toast import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.EntryPoint import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.Toaster import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -21,6 +27,17 @@ abstract class AppModule { abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository } +@Module +@InstallIn(SingletonComponent::class) +class ToasterModule { + @Provides + @Singleton + fun provideToaster(@ApplicationContext context: Context) = Toaster { stringRes, toastLength, parameters -> + val string = context.getString(stringRes, parameters) + Toast.makeText(context, string, toastLength).show() + } +} + @EntryPoint @InstallIn(SingletonComponent::class) interface AppComponent { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt index da15c2f6b45..8e0c7357708 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt @@ -1,16 +1,12 @@ package org.thoughtcrime.securesms.dependencies import android.content.Context -import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ServiceComponent import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.android.scopes.ServiceScoped import dagger.hilt.components.SingletonComponent -import org.session.libsession.database.CallDataProvider -import org.thoughtcrime.securesms.database.Storage +import org.session.libsession.database.StorageProtocol import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import javax.inject.Singleton @@ -25,7 +21,7 @@ object CallModule { @Provides @Singleton - fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) = + fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: StorageProtocol) = CallManager(context, audioManagerCompat, storage) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 505a7939a8b..058e276d8c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -2,16 +2,27 @@ package org.thoughtcrime.securesms.dependencies import android.content.Context import android.os.Trace +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import network.loki.messenger.libsession_util.Config import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.GroupInfoConfig +import network.loki.messenger.libsession_util.GroupKeysConfig +import network.loki.messenger.libsession_util.GroupMembersConfig import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserProfile +import network.loki.messenger.libsession_util.util.Sodium +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.groups.GroupManager @@ -20,6 +31,7 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class ConfigFactory( private val context: Context, private val configDatabase: ConfigDatabase, + /** */ private val maybeGetUserInfo: () -> Pair? ) : ConfigFactoryProtocol { @@ -28,10 +40,10 @@ class ConfigFactory( // config change, any message which would normally result in a config change which was sent // before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have // it's changes applied (control text will still be added though) - val configChangeBufferPeriod: Long = (2 * 60 * 1000) + const val configChangeBufferPeriod: Long = (2 * 60 * 1000) } - fun keyPairChanged() { // this should only happen restoring or clearing data + fun keyPairChanged() { // this should only happen restoring or clearing datac _userConfig?.free() _contacts?.free() _convoVolatileConfig?.free() @@ -52,6 +64,13 @@ class ConfigFactory( private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) } private val listeners: MutableList = mutableListOf() + + private val _configUpdateNotifications = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val configUpdateNotifications get() = _configUpdateNotifications + fun registerListener(listener: ConfigFactoryUpdateListener) { listeners += listener } @@ -60,7 +79,7 @@ class ConfigFactory( listeners -= listener } - private inline fun synchronizedWithLog(lock: Any, body: ()->T): T { + private inline fun synchronizedWithLog(lock: Any, body: () -> T): T { Trace.beginSection("synchronizedWithLog") val result = synchronized(lock) { body() @@ -146,6 +165,101 @@ class ConfigFactory( _userGroups } + private fun getGroupInfo(groupSessionId: AccountId) = userGroups?.getClosedGroup(groupSessionId.hexString) + + override fun getGroupInfoConfig(groupSessionId: AccountId): GroupInfoConfig? = getGroupInfo(groupSessionId)?.let { groupInfo -> + // get any potential initial dumps + val dump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.INFO_VARIANT, + groupSessionId.hexString + ) ?: byteArrayOf() + + GroupInfoConfig.newInstance(groupSessionId.pubKeyBytes, groupInfo.adminKey, dump) + } + + override fun getGroupKeysConfig(groupSessionId: AccountId, + info: GroupInfoConfig?, + members: GroupMembersConfig?, + free: Boolean): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo -> + // Get the user info or return early + val (userSk, _) = maybeGetUserInfo() ?: return@let null + + // Get the group info or return early + val usedInfo = info ?: getGroupInfoConfig(groupSessionId) ?: return@let null + + // Get the group members or return early + val usedMembers = members ?: getGroupMemberConfig(groupSessionId) ?: return@let null + + // Get the dump or empty + val dump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.KEYS_VARIANT, + groupSessionId.hexString + ) ?: byteArrayOf() + + // Put it all together + val keys = GroupKeysConfig.newInstance( + userSk, + groupSessionId.pubKeyBytes, + groupInfo.adminKey, + dump, + usedInfo, + usedMembers + ) + if (free) { + info?.free() + members?.free() + } + if (usedInfo !== info) usedInfo.free() + if (usedMembers !== members) usedMembers.free() + keys + } + + override fun getGroupMemberConfig(groupSessionId: AccountId): GroupMembersConfig? = getGroupInfo(groupSessionId)?.let { groupInfo -> + // Get initial dump if we have one + val dump = configDatabase.retrieveConfigAndHashes( + ConfigDatabase.MEMBER_VARIANT, + groupSessionId.hexString + ) ?: byteArrayOf() + + GroupMembersConfig.newInstance( + groupSessionId.pubKeyBytes, + groupInfo.adminKey, + dump + ) + } + + override fun constructGroupKeysConfig( + groupSessionId: AccountId, + info: GroupInfoConfig, + members: GroupMembersConfig + ): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo -> + val (userSk, _) = maybeGetUserInfo() ?: return null + GroupKeysConfig.newInstance( + userSk, + groupSessionId.pubKeyBytes, + groupInfo.adminKey, + info = info, + members = members + ) + } + + override fun userSessionId(): AccountId? { + return maybeGetUserInfo()?.second?.let(::AccountId) + } + + override fun maybeDecryptForUser(encoded: ByteArray, domain: String, closedGroupSessionId: AccountId): ByteArray? { + val secret = maybeGetUserInfo()?.first ?: run { + Log.e("ConfigFactory", "No user ed25519 secret key decrypting a message for us") + return null + } + return Sodium.decryptForMultipleSimple( + encoded = encoded, + ed25519SecretKey = secret, + domain = domain, + senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes) + ) + } + override fun getUserConfigs(): List = listOfNotNull(user, contacts, convoVolatile, userGroups) @@ -153,13 +267,23 @@ class ConfigFactory( private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) { val dumped = user?.dump() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return - configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp) + configDatabase.storeConfig( + SharedConfigMessage.Kind.USER_PROFILE.name, + publicKey, + dumped, + timestamp + ) } private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) { val dumped = contacts?.dump() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return - configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp) + configDatabase.storeConfig( + SharedConfigMessage.Kind.CONTACTS.name, + publicKey, + dumped, + timestamp + ) } private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) { @@ -176,21 +300,52 @@ class ConfigFactory( private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) { val dumped = userGroups?.dump() ?: return val (_, publicKey) = maybeGetUserInfo() ?: return - configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp) + configDatabase.storeConfig( + SharedConfigMessage.Kind.GROUPS.name, + publicKey, + dumped, + timestamp + ) } - override fun persist(forConfigObject: ConfigBase, timestamp: Long) { + fun persistGroupConfigDump(forConfigObject: ConfigBase, groupSessionId: AccountId, timestamp: Long) = synchronized(userGroupsLock) { + val dumped = forConfigObject.dump() + val variant = when (forConfigObject) { + is GroupMembersConfig -> ConfigDatabase.MEMBER_VARIANT + is GroupInfoConfig -> ConfigDatabase.INFO_VARIANT + else -> throw Exception("Shouldn't be called") + } + configDatabase.storeConfig( + variant, + groupSessionId.hexString, + dumped, + timestamp + ) + _configUpdateNotifications.tryEmit(Unit) + } + + override fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String?) { try { + if (forConfigObject is ConfigBase && !forConfigObject.needsDump() || forConfigObject is GroupKeysConfig && !forConfigObject.needsDump()) { + Log.d("ConfigFactory", "Don't need to persist ${forConfigObject.javaClass} for $forPublicKey pubkey") + return + } + listeners.forEach { listener -> listener.notifyUpdates(forConfigObject, timestamp) } + when (forConfigObject) { is UserProfile -> persistUserConfigDump(timestamp) is Contacts -> persistContactsConfigDump(timestamp) is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp) is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp) + is GroupMembersConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp) + is GroupInfoConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp) else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet") } + + _configUpdateNotifications.tryEmit(Unit) } catch (e: Exception) { Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e) } @@ -207,23 +362,25 @@ class ConfigFactory( if (openGroupId != null) { val userGroups = userGroups ?: return false val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context) - val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false + val openGroup = + get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false // Not handling the `hidden` behaviour for communities so just indicate the existence return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null) - } - else if (groupPublicKey != null) { + } else if (groupPublicKey != null) { val userGroups = userGroups ?: return false // Not handling the `hidden` behaviour for legacy groups so just indicate the existence - return (userGroups.getLegacyGroupInfo(groupPublicKey) != null) - } - else if (publicKey == userPublicKey) { + return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) { + userGroups.getClosedGroup(groupPublicKey) != null + } else { + userGroups.getLegacyGroupInfo(groupPublicKey) != null + } + } else if (publicKey == userPublicKey) { val user = user ?: return false return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN) - } - else if (publicKey != null) { + } else if (publicKey != null) { val contacts = contacts ?: return false val targetContact = contacts.get(publicKey) ?: return false @@ -233,10 +390,44 @@ class ConfigFactory( return false } - override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { - val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) + override fun canPerformChange( + variant: String, + publicKey: String, + changeTimestampMs: Long + ): Boolean { + val lastUpdateTimestampMs = + configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) // Ensure the change occurred after the last config message was handled (minus the buffer period) - return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod)) + return (changeTimestampMs >= (lastUpdateTimestampMs - configChangeBufferPeriod)) + } + + override fun saveGroupConfigs( + groupKeys: GroupKeysConfig, + groupInfo: GroupInfoConfig, + groupMembers: GroupMembersConfig + ) { + val pubKey = groupInfo.id().hexString + val timestamp = SnodeAPI.nowWithOffset + + // this would be nicer with a .any iteration or something but the base types don't line up + val anyNeedDump = groupKeys.needsDump() || groupInfo.needsDump() || groupMembers.needsDump() + if (!anyNeedDump) return Log.d("ConfigFactory", "Group config doesn't need dump, skipping") + else Log.d("ConfigFactory", "Group config needs dump, storing and notifying") + + configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp) + _configUpdateNotifications.tryEmit(Unit) + } + + override fun removeGroup(closedGroupId: AccountId) { + val groups = userGroups ?: return + groups.eraseClosedGroup(closedGroupId.hexString) + persist(groups, SnodeAPI.nowWithOffset) + configDatabase.deleteGroupConfigs(closedGroupId) + } + + override fun scheduleUpdate(destination: Destination) { + // there's probably a better way to do this + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt new file mode 100644 index 00000000000..5a1dee052a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt @@ -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 + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index c037f3b27a2..82c4925b97f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -5,6 +5,7 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.MmsSmsDatabase diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 30fb40d89a2..85d2bc27d2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -141,8 +141,13 @@ object DatabaseModule { @Provides @Singleton - fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { - val storage = Storage(context,openHelper, configFactory) + fun provideStorage(@ApplicationContext context: Context, + openHelper: SQLCipherOpenHelper, + configFactory: ConfigFactory, + threadDatabase: ThreadDatabase, + pollerFactory: PollerFactory, + ): Storage { + val storage = Storage(context, openHelper, configFactory, pollerFactory) threadDatabase.setUpdateListener(storage) return storage } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt new file mode 100644 index 00000000000..27dbdec92c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt @@ -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() + + 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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt index cd4b0713382..7681536af3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -6,16 +6,24 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.database.ConfigDatabase +import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object SessionUtilModule { + const val POLLER_SCOPE = "poller_coroutine_scope" + private fun maybeUserEdSecretKey(context: Context): ByteArray? { val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null return edKey.secretKey.asBytes @@ -33,4 +41,19 @@ object SessionUtilModule { registerListener(context as ConfigFactoryUpdateListener) } + @Provides + @Named(POLLER_SCOPE) + fun providePollerScope(@ApplicationContext applicationContext: Context): CoroutineScope = GlobalScope + + @OptIn(ExperimentalCoroutinesApi::class) + @Provides + @Named(POLLER_SCOPE) + fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + + @Provides + @Singleton + fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope, + @Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher, + configFactory: ConfigFactory) = PollerFactory(coroutineScope, dispatcher, configFactory) + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt index adeeeb91fa1..96c0c7c8827 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -4,7 +4,7 @@ import android.content.Context import network.loki.messenger.libsession_util.ConfigBase import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil @@ -25,7 +25,7 @@ object ClosedGroupManager { // Notify the PN server PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey) // Stop polling - ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey) storage.cancelPendingMessageSendJobs(threadId) ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) if (delete) { @@ -33,16 +33,9 @@ object ClosedGroupManager { } } - fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean { - val groups = userGroups ?: return false - if (!group.isClosedGroup) return false - val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) - return groups.eraseLegacyGroup(groupPublicKey) - } - fun ConfigFactory.updateLegacyGroup(group: GroupRecord) { val groups = userGroups ?: return - if (!group.isClosedGroup) return + if (!group.isLegacyClosedGroup) return val storage = MessagingModuleConfiguration.shared.storage val threadId = storage.getThreadId(group.encodedId) ?: return val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt index 0f562c80b7d..348c9caa575 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -1,127 +1,44 @@ package org.thoughtcrime.securesms.groups -import android.content.Context import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentCreateGroupBinding -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.groupSizeLimit -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Device -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.contacts.SelectContactsAdapter +import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView -import com.bumptech.glide.Glide -import org.thoughtcrime.securesms.util.fadeIn -import org.thoughtcrime.securesms.util.fadeOut -import javax.inject.Inject +import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme -@AndroidEntryPoint class CreateGroupFragment : Fragment() { - - @Inject - lateinit var device: Device - - private lateinit var binding: FragmentCreateGroupBinding - private val viewModel: CreateGroupViewModel by viewModels() - - lateinit var delegate: StartConversationDelegate - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = FragmentCreateGroupBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val adapter = SelectContactsAdapter(requireContext(), Glide.with(requireContext())) - binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } - binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } - binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks { - override fun onQueryChanged(query: String) { - adapter.members = viewModel.filter(query).map { it.address.serialize() } - } - } - binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() } - binding.recyclerView.adapter = adapter - val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let { - DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply { - setDrawable(it) + return ComposeView(requireContext()).apply { + val delegate = (parentFragment as? StartConversationDelegate) + ?: (activity as? StartConversationDelegate) + ?: NullStartConversationDelegate + + setContent { + SessionMaterialTheme { + CreateGroupScreen( + onNavigateToConversationScreen = { threadID -> + startActivity( + Intent(requireContext(), ConversationActivityV2::class.java) + .putExtra(ConversationActivityV2.THREAD_ID, threadID) + ) + }, + onBack = delegate::onDialogBackPressed, + onClose = delegate::onDialogClosePressed + ) + } } } - 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) } +} -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt index b3dbb49384a..04967fe91b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt @@ -1,46 +1,93 @@ package org.thoughtcrime.securesms.groups -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.ThreadDatabase +import org.session.libsession.database.StorageProtocol +import org.thoughtcrime.securesms.dependencies.ConfigFactory import javax.inject.Inject + @HiltViewModel class CreateGroupViewModel @Inject constructor( - private val threadDb: ThreadDatabase, - private val textSecurePreferences: TextSecurePreferences -) : ViewModel() { + configFactory: ConfigFactory, + private val storage: StorageProtocol, +): ViewModel() { + // Child view model to handle contact selection logic + val selectContactsViewModel = SelectContactsViewModel( + storage = storage, + configFactory = configFactory, + excludingAccountIDs = emptySet(), + scope = viewModelScope, + ) + + // Input: group name + private val mutableGroupName = MutableStateFlow("") + private val mutableGroupNameError = MutableStateFlow("") + + // Output: group name + val groupName: StateFlow get() = mutableGroupName + val groupNameError: StateFlow get() = mutableGroupNameError + + // Output: loading state + private val mutableIsLoading = MutableStateFlow(false) + val isLoading: StateFlow get() = mutableIsLoading - private val _recipients = MutableLiveData>() - val recipients: LiveData> = _recipients + // Events + private val mutableEvents = MutableSharedFlow() + val events: SharedFlow get() = mutableEvents - init { + fun onCreateClicked() { viewModelScope.launch { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - val recipients = mutableListOf() - while (true) { - recipients += reader.next?.recipient ?: break - } - withContext(Dispatchers.Main) { - _recipients.value = recipients - .filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() } - } + val groupName = groupName.value.trim() + if (groupName.isBlank()) { + mutableGroupNameError.value = "Group name cannot be empty" + return@launch + } + + val selected = selectContactsViewModel.currentSelected + if (selected.isEmpty()) { + mutableEvents.emit(CreateGroupEvent.Error("Please select at least one contact")) + return@launch + } + + mutableIsLoading.value = true + + val recipient = withContext(Dispatchers.Default) { + storage.createNewGroup(groupName, "", selected) + } + + if (recipient.isPresent) { + val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.get().address) } + mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId)) + } else { + mutableEvents.emit(CreateGroupEvent.Error("Failed to create group")) } + + mutableIsLoading.value = false } } - fun filter(query: String): List { - return _recipients.value?.filter { - it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true - } ?: emptyList() + fun onGroupNameChanged(name: String) { + mutableGroupName.value = if (name.length > MAX_GROUP_NAME_LENGTH) { + name.substring(0, MAX_GROUP_NAME_LENGTH) + } else { + name + } + + mutableGroupNameError.value = "" } +} + +sealed interface CreateGroupEvent { + data class NavigateToConversation(val threadID: Long): CreateGroupEvent + + data class Error(val message: String): CreateGroupEvent } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt new file mode 100644 index 00000000000..7a86f2b553e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupInviteViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupInviteViewModel.kt new file mode 100644 index 00000000000..c9975756568 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupInviteViewModel.kt @@ -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, + val allContacts: Set +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt new file mode 100644 index 00000000000..dbc22057e94 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -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(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 get() = mutableEditingName + + // Output: the source-of-truth group information. Other states are derived from this. + private val groupInfo: StateFlow>?> = + 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 = 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 = 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> = groupInfo + .map { it?.second.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + // Output: whether we should show the "add members" button + val showAddMembers: StateFlow = groupInfo + .map { it?.first?.isUserAdmin == true } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + // Output: Intermediate states + private val mutableInProgress = MutableStateFlow(false) + val inProgress: StateFlow get() = mutableInProgress + + // Output: errors + private val mutableError = MutableStateFlow(null) + val error: StateFlow get() = mutableError + + // Output: + val excludingAccountIDsFromContactSelection: Set + 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, 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) { + 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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt similarity index 71% rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt index b1e0b5e1d82..3c34395c8bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt @@ -4,13 +4,13 @@ import android.content.Context import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.AsyncLoader -class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) { +class EditLegacyClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) { - override fun loadInBackground(): EditClosedGroupActivity.GroupMembers { + override fun loadInBackground(): EditLegacyGroupActivity.GroupMembers { val groupDatabase = DatabaseComponent.get(context).groupDatabase() val members = groupDatabase.getGroupMembers(groupID, true) val zombieMembers = groupDatabase.getGroupZombieMembers(groupID) - return EditClosedGroupActivity.GroupMembers( + return EditLegacyGroupActivity.GroupMembers( members.map { it.address.toString() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt similarity index 91% rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt index 11dde4b93ed..f76673feaa8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt @@ -18,15 +18,12 @@ import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import java.io.IOException import javax.inject.Inject import network.loki.messenger.R -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.task -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.groupSizeLimit import org.session.libsession.utilities.Address @@ -43,12 +40,11 @@ import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup -import com.bumptech.glide.Glide import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut @AndroidEntryPoint -class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { +class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() { @Inject lateinit var groupConfigFactory: ConfigFactory @@ -80,9 +76,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { private val memberListAdapter by lazy { if (isSelfAdmin) - EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick) + EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick) else - EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin) + EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin) } private lateinit var mainContentContainer: LinearLayout @@ -129,7 +125,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { findViewById(R.id.rvUserList).apply { adapter = memberListAdapter - layoutManager = LinearLayoutManager(this@EditClosedGroupActivity) + layoutManager = LinearLayoutManager(this@EditLegacyGroupActivity) } lblGroupNameDisplay.text = originalName @@ -162,13 +158,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID) + return EditLegacyClosedGroupLoader(this@EditLegacyGroupActivity, groupID) } override fun onLoadFinished(loader: Loader, groupMembers: GroupMembers) { // We no longer need any subsequent loading events // (they will occur on every activity resume). - LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID) + LoaderManager.getInstance(this@EditLegacyGroupActivity).destroyLoader(loaderID) members.clear() members.addAll(groupMembers.members.toHashSet()) @@ -192,7 +188,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { // endregion // region Updating - @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { @@ -252,7 +247,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } private fun onAddMembersClick() { - val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java) + val intent = Intent(this@EditLegacyGroupActivity, SelectContactsActivity::class.java) intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray()) intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add") startActivityForResult(intent, addUsersRequestCode) @@ -320,10 +315,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { if (isClosedGroup) { isLoading = true loaderContainer.fadeIn() - val promise: Promise = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { - MessageSender.explicitLeave(groupPublicKey!!, false) - } else { - task { + try { + if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { + MessageSender.explicitLeave(groupPublicKey!!, false) + } else { if (hasNameChanged) { MessageSender.explicitNameChange(groupPublicKey!!, name) } @@ -334,15 +329,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() }) } } - } - promise.successUi { loaderContainer.fadeOut() isLoading = false updateGroupConfig() finish() - }.failUi { exception -> + } catch (exception: Exception) { val message = if (exception is MessageSender.Error) exception.description else "An error occurred" - Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() + Toast.makeText(this@EditLegacyGroupActivity, message, Toast.LENGTH_LONG).show() loaderContainer.fadeOut() isLoading = false } @@ -350,8 +343,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } private fun updateGroupConfig() { - val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID)) - ?: return Log.w("Loki", "No recipient settings when trying to update group config") val latestGroup = storage.getGroup(groupID) ?: return Log.w("Loki", "No group record when trying to update group config") groupConfigFactory.updateLegacyGroup(latestGroup) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt similarity index 95% rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt index 5127e3be724..248b858376a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt @@ -9,12 +9,12 @@ import com.bumptech.glide.RequestManager import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.TextSecurePreferences -class EditClosedGroupMembersAdapter( +class EditLegacyGroupMembersAdapter( private val context: Context, private val glide: RequestManager, private val admin: Boolean, private val memberClickListener: ((String) -> Unit)? = null -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { private val members = ArrayList() private val zombieMembers = ArrayList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt new file mode 100644 index 00000000000..0b18b4e7a7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt @@ -0,0 +1,2 @@ +package org.thoughtcrime.securesms.groups + diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt new file mode 100644 index 00000000000..68cce86e0a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -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, + @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()) + + // Output: The search query + val searchQuery: StateFlow get() = mutableSearchQuery + + // Output: the contact items to display and select from + val contacts: StateFlow> = combine( + observeContacts(), + mutableSearchQuery.debounce(100L), + mutableSelectedContactAccountIDs, + ::filterContacts + ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // Output + val currentSelected: Set + 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) + .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, + query: String, + selectedAccountIDs: Set + ): List { + 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 = 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() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt new file mode 100644 index 00000000000..16a596b047d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -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, + 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 = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt new file mode 100644 index 00000000000..733f3900862 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt @@ -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, + 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 = "", + ) + } + +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt new file mode 100644 index 00000000000..0e1ebb75e44 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -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 { factory -> + factory.create(groupSessionId) + } + + NavHost(navController = navController, startDestination = RouteEditGroup) { + composable { + 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 { + 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, + showAddMembers: Boolean, + showingError: String?, + onErrorDismissed: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + + val (showingBottomModelForMember, setShowingBottomModelForMember) = remember { + mutableStateOf(null) + } + + val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember { + mutableStateOf(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(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 = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt new file mode 100644 index 00000000000..edfbdf2ce46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt @@ -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 = emptySet(), + onDoneClicked: (selectedContacts: Set) -> Unit, + onBackClicked: () -> Unit, +) { + val viewModel = hiltViewModel { 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, + 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 + ) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 82b9f16dcda..3f6fe57352f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -9,6 +9,8 @@ import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.getConversationUnread @@ -21,7 +23,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto // is not the best idea. It doesn't survive configuration change. // We should be dealing with IDs and all sorts of serializable data instead // if we want to use dialog fragments properly. + lateinit var publicKey: String lateinit var thread: ThreadRecord + var group: GroupRecord? = null @Inject lateinit var configFactory: ConfigFactory @@ -51,6 +55,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.blockTextView -> onBlockTapped?.invoke() binding.unblockTextView -> onUnblockTapped?.invoke() binding.deleteTextView -> onDeleteTapped?.invoke() + binding.leaveTextView -> onDeleteTapped?.invoke() binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke() binding.notificationsTextView -> onNotificationTapped?.invoke() binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false) @@ -62,6 +67,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto super.onViewCreated(view, savedInstanceState) if (!this::thread.isInitialized) { return dismiss() } val recipient = thread.recipient + val isCurrentUserInGroup = group?.members?.map { it.toString() }?.contains(publicKey) ?: false if (!recipient.isGroupRecipient && !recipient.isLocalNumber) { binding.detailsTextView.visibility = View.VISIBLE binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE @@ -82,7 +88,10 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.muteNotificationsTextView.setOnClickListener(this) binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.setOnClickListener(this) + binding.deleteTextView.isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup) binding.deleteTextView.setOnClickListener(this) + binding.leaveTextView.isVisible = recipient.isGroupRecipient && isCurrentUserInGroup + binding.leaveTextView.setOnClickListener(this) binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true binding.markAllAsReadTextView.setOnClickListener(this) binding.pinTextView.isVisible = !thread.isPinned diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index d87941fcc10..905f2d2dd17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.Resources import android.graphics.Typeface import android.graphics.drawable.ColorDrawable -import android.text.TextUtils import android.util.AttributeSet import android.util.TypedValue import android.view.View @@ -16,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.ThemeUtil +import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL @@ -50,6 +50,16 @@ class ConversationView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, isTyping: Boolean) { + if (thread.isLeavingGroup) { + binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary)) + binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary)) + } else if (thread.isErrorLeavingGroup) { + binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) + binding.snippetTextView.setTextColor(context.getColorFromAttr(R.attr.danger)) + } else { + binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) + binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) + } this.thread = thread if (thread.isPinned) { binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 5af7e7be520..d9cbd13cc5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -36,6 +36,7 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.LibSessionGroupLeavingJob import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address @@ -43,6 +44,7 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.toHexString @@ -71,7 +73,6 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager -import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity @@ -116,7 +117,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var configFactory: ConfigFactory - @Inject lateinit var pushRegistry: PushRegistry private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -140,9 +140,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) } is GlobalSearchAdapter.Model.Contact -> push { - putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized)) + putExtra( + ConversationActivityV2.ADDRESS, + model.contact.accountID.let(Address::fromSerialized) + ) } - is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId + + is GlobalSearchAdapter.Model.LegacyGroupConversation -> model.groupRecord.encodedId .let { Recipient.from(this, Address.fromSerialized(it), false) } .let(threadDb::getThreadIdIfExistsFor) .takeIf { it >= 0 } @@ -238,7 +242,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), (applicationContext as ApplicationContext).startPollingIfNeeded() // update things based on TextSecurePrefs (profile info etc) // Set up remaining components if needed - pushRegistry.refresh(false) if (textSecurePreferences.getLocalNumber() != null) { OpenGroupManager.startPolling() JobQueue.shared.resumePendingJobs() @@ -330,7 +333,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private val GlobalSearchResult.contactAndGroupList: List get() = contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } + - threads.map(GlobalSearchAdapter.Model::GroupConversation) + threads.map(GlobalSearchAdapter.Model::LegacyGroupConversation) private val GlobalSearchResult.messageResults: List get() { val unreadThreadMap = messages @@ -428,7 +431,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), override fun onLongConversationClick(thread: ThreadRecord) { val bottomSheet = ConversationOptionsBottomSheet(this) + bottomSheet.publicKey = publicKey bottomSheet.thread = thread + bottomSheet.group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull() bottomSheet.onViewDetailsTapped = { bottomSheet.dismiss() val userDetailsBottomSheet = UserDetailsBottomSheet() @@ -588,14 +593,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), val group = groupDatabase.getGroup(recipient.address.toString()).orNull() // If you are an admin of this group you can delete it - if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) { + if (group != null && group.admins.map { it.toString() } + .contains(textSecurePreferences.getLocalNumber())) { title = getString(R.string.groupDelete) message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription) .put(GROUP_NAME_KEY, group.title) .format() } else { // Otherwise this is either a community, or it's a group you're not an admin of - title = if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(R.string.groupLeave) + title = + if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString( + R.string.groupLeave + ) message = Phrase.from(this.applicationContext, R.string.groupLeaveDescription) .put(GROUP_NAME_KEY, group.title) .format() @@ -622,25 +631,34 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), lifecycleScope.launch(Dispatchers.Main) { val context = this@HomeActivity // Cancel any outstanding jobs - DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) + DatabaseComponent.get(context).sessionJobDatabase() + .cancelPendingMessageSendJobs(threadID) // Send a leave group message if this is an active closed group - if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { + if (recipient.address.isLegacyClosedGroup && DatabaseComponent.get(context) + .groupDatabase().isActive(recipient.address.toGroupString()) + ) { try { - GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() + GroupUtil.doubleDecodeGroupID(recipient.address.toString()) + .toHexString() .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) - ?.let { MessageSender.explicitLeave(it, false) } + ?.let { MessageSender.explicitLeave(it, true, deleteThread = true) } } catch (ioe: IOException) { - Log.w(TAG, "Got an IOException while sending leave group message") + Log.w(TAG, "Got an IOException while sending leave group message", ioe) } } + if (recipient.address.isClosedGroupV2) { + val groupLeave = LibSessionGroupLeavingJob(AccountId(recipient.address.serialize()), true) + JobQueue.shared.add(groupLeave) + } // Delete the conversation - val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) + val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase() + .getOpenGroupChat(threadID) if (v2OpenGroup != null) { - v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) } - } else { - lifecycleScope.launch(Dispatchers.IO) { - threadDb.deleteConversation(threadID) - } + OpenGroupManager.delete( + v2OpenGroup.server, + v2OpenGroup.room, + context + ) } // Update the badge count ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index dd6d24cd00d..884df78c9a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -80,7 +80,7 @@ class HomeViewModel @Inject constructor( ).flowOn(Dispatchers.IO) private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() - .map { threadDb.unapprovedConversationCount } + .map { threadDb.unapprovedConversationList.use { cursor -> cursor.count } } private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges() .map { threadDb.latestUnapprovedConversationTimestamp } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 71c2c625068..70af080b76d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -11,7 +11,7 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.ui.GetString import java.security.InvalidParameterException @@ -116,7 +116,7 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie fun bind(query: String, model: Model) { binding.searchResultProfilePicture.recycle() when (model) { - is Model.GroupConversation -> bindModel(query, model) + is Model.LegacyGroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) is Model.Message -> bindModel(query, model) is Model.SavedMessages -> bindModel(model) @@ -136,8 +136,9 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie constructor(title: String): this(GetString(title)) } data class SavedMessages(val currentUserPublicKey: String): Model() - data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model() - data class GroupConversation(val groupRecord: GroupRecord): Model() - data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model() + data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model() + data class LegacyGroupConversation(val groupRecord: GroupRecord) : Model() + data class ClosedGroupConversation(val sessionId: AccountId) + data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : Model() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 947edc3d8e1..9d466d7463c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -14,7 +14,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel -import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.LegacyGroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages @@ -66,7 +66,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { binding.searchResultSubtitle.isVisible = true binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName() } - is GroupConversation -> { + is LegacyGroupConversation -> { binding.searchResultTitle.text = getHighlight( query, model.groupRecord.title @@ -87,9 +87,9 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query) } -fun ContentView.bindModel(query: String?, model: GroupConversation) { +fun ContentView.bindModel(query: String?, model: LegacyGroupConversation) { binding.searchResultProfilePicture.isVisible = true - binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup + binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) binding.searchResultProfilePicture.update(threadRecipient) @@ -99,7 +99,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) } val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName) - if (model.groupRecord.isClosedGroup) { + if (model.groupRecord.isLegacyClosedGroup) { binding.searchResultSubtitle.text = getHighlight(query, membersString) } } @@ -127,13 +127,6 @@ fun ContentView.bindModel(model: SavedMessages) { fun ContentView.bindModel(query: String?, model: Message) = binding.apply { searchResultProfilePicture.isVisible = true searchResultTimestamp.isVisible = true - -// val hasUnreads = model.unread > 0 -// unreadCountIndicator.isVisible = hasUnreads -// if (hasUnreads) { -// unreadCountTextView.text = model.unread.toString() -// } - searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 93f79d2b165..0e9865c01ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityMessageRequestsBinding import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase @@ -80,7 +82,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat override fun onBlockConversationClick(thread: ThreadRecord) { fun doBlock() { - viewModel.blockMessageRequest(thread) + val recipient = thread.invitingAdminId?.let { + Recipient.from(this, Address.fromSerialized(it), false) + } ?: thread.recipient + viewModel.blockMessageRequest(thread, recipient) LoaderManager.getInstance(this).restartLoader(0, null, this) } @@ -108,7 +113,11 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat showSessionDialog { title(R.string.delete) text(resources.getString(R.string.messageRequestsDelete)) - button(R.string.delete) { doDecline() } + if (thread.recipient.isClosedGroupV2Recipient) { + dangerButton(R.string.delete, contentDescriptionRes = R.string.delete) { doDecline() } + } else { + dangerButton(R.string.decline, contentDescriptionRes = R.string.decline) { doDecline() } + } button(R.string.cancel) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index cb352d83b7e..59ab2675877 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -31,7 +31,9 @@ class MessageRequestsAdapter( val view = MessageRequestView(context) view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } view.setOnLongClickListener { - view.thread?.let { showPopupMenu(view) } + view.thread?.let { thread -> + showPopupMenu(view, thread.recipient.isGroupRecipient, thread.invitingAdminId) + } true } return ViewHolder(view) @@ -47,10 +49,14 @@ class MessageRequestsAdapter( holder?.view?.recycle() } - private fun showPopupMenu(view: MessageRequestView) { + private fun showPopupMenu(view: MessageRequestView, groupRecipient: Boolean, invitingAdmin: String?) { val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) - popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) - popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient + // still show the block option if we have an inviting admin for the group + if ((groupRecipient && invitingAdmin == null) || view.thread!!.recipient.isOpenGroupInboxRecipient) { + popupMenu.menuInflater.inflate(R.menu.menu_group_request, popupMenu.menu) + } else { + popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) + } popupMenu.setOnMenuItemClickListener { menuItem -> if (menuItem.itemId == R.id.menu_delete_message_request) { listener.onDeleteConversationClick(view.thread!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt index a3a7caf8d2c..d9003d005a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.repository.ConversationRepository import javax.inject.Inject @@ -13,12 +14,10 @@ class MessageRequestsViewModel @Inject constructor( private val repository: ConversationRepository ) : ViewModel() { - fun blockMessageRequest(thread: ThreadRecord) = viewModelScope.launch { - val recipient = thread.recipient - if (recipient.isContactRecipient) { - repository.setBlocked(recipient, true) - deleteMessageRequest(thread) - } + // We assume thread.recipient is a contact or thread.invitingAdmin is not null + fun blockMessageRequest(thread: ThreadRecord, blockRecipient: Recipient) = viewModelScope.launch { + repository.setBlocked(thread.threadId, blockRecipient, true) + deleteMessageRequest(thread) } fun deleteMessageRequest(thread: ThreadRecord) = viewModelScope.launch { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 63f6d07da1e..25ff4c8a102 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -7,20 +7,24 @@ import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters +import kotlinx.coroutines.GlobalScope import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind +import org.session.libsession.database.userAuth import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.asyncPromise +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.recover @@ -108,20 +112,23 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor var dmsPromise: Promise = Promise.ofSuccess(Unit) if (requestTargets.contains(Targets.DMS)) { - val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes -> + val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth) + dmsPromise = SnodeAPI.getMessages(userAuth).bind { envelopes -> val params = envelopes.map { (envelope, serverHash) -> // FIXME: Using a job here seems like a bad idea... MessageReceiveParameters(envelope.toByteArray(), serverHash, null) } - BatchMessageReceiveJob(params).executeAsync("background") + + GlobalScope.asyncPromise { + BatchMessageReceiveJob(params).executeAsync("background") + } } promises.add(dmsPromise) } // Closed groups if (requestTargets.contains(Targets.CLOSED_GROUPS)) { - val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared + val closedGroupPoller = LegacyClosedGroupPollerV2() // Intentionally don't use shared val storage = MessagingModuleConfiguration.shared.storage val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index cbf53e6a8b3..7f6bae5e171 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -43,11 +43,9 @@ import kotlin.concurrent.Volatile import me.leolin.shortcutbadger.ShortcutBadger import network.loki.messenger.R import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ServiceUtil -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy @@ -56,6 +54,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.hasHidde import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Util diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 59681c1f8a5..505e8f0ec3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -6,13 +6,13 @@ import android.content.Context import android.content.Intent import android.os.AsyncTask import androidx.core.app.NotificationManagerCompat +import org.session.libsession.database.userAuth import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.sending_receiving.MessageSender.send import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.utilities.SSKEnvironment -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.associateByNotNull import org.session.libsession.utilities.recipients.Recipient @@ -102,7 +102,7 @@ class MarkReadReceiver : BroadcastReceiver() { SnodeAPI.alterTtl( messageHashes = hashes, newExpiry = nowWithOffset + expiresIn, - publicKey = TextSecurePreferences.getLocalNumber(context)!!, + auth = checkNotNull(shared.storage.userAuth) { "No authorized user" }, shorten = true ) } @@ -130,7 +130,7 @@ class MarkReadReceiver : BroadcastReceiver() { hashToMessage: Map ) { @Suppress("UNCHECKED_CAST") - val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map + val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), shared.storage.userAuth!!).get()["expiries"] as Map hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 8eaca4000b1..d592836440b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -1,54 +1,110 @@ package org.thoughtcrime.securesms.notifications import android.content.Context +import android.content.pm.PackageManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString import com.goterl.lazysodium.interfaces.AEAD import com.goterl.lazysodium.utils.Key import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import org.session.libsession.snode.GroupSubAccountSwarmAuth +import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeString +import org.session.libsession.utilities.withGroupConfigsOrNull +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.Envelope +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.dependencies.ConfigFactory import javax.inject.Inject private const val TAG = "PushHandler" -class PushReceiver @Inject constructor(@ApplicationContext val context: Context) { +class PushReceiver @Inject constructor( + @ApplicationContext private val context: Context, + private val configFactory: ConfigFactory +) { private val json = Json { ignoreUnknownKeys = true } fun onPush(dataMap: Map?) { - onPush(dataMap?.asByteArray()) - } - - fun onPush(data: ByteArray?) { + val result = dataMap?.decodeAndDecrypt() + val data = result?.first if (data == null) { onPush() return } + handlePushData(data = data, metadata = result.second) + } + + private fun handlePushData(data: ByteArray, metadata: PushNotificationMetadata?) { try { - val envelopeAsData = MessageWrapper.unwrap(data).toByteArray() - val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null) - JobQueue.shared.add(job) + val params = when { + metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> { + val groupId = AccountId(requireNotNull(metadata.account) { + "Received a closed group message push notification without an account ID" + }) + + val envelop = checkNotNull(tryDecryptGroupMessage(groupId, data)) { + "Unable to decrypt closed group message" + } + + MessageReceiveParameters( + data = envelop.toByteArray(), + serverHash = metadata.msg_hash, + closedGroup = Destination.ClosedGroup(groupId.hexString) + ) + } + + metadata?.namespace == 0 || metadata == null -> { + MessageReceiveParameters( + data = MessageWrapper.unwrap(data).toByteArray(), + ) + } + + else -> { + Log.w(TAG, "Received a push notification with an unknown namespace: ${metadata.namespace}") + return + } + } + + JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null)) } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) } } + private fun tryDecryptGroupMessage(groupId: AccountId, data: ByteArray): Envelope? { + return configFactory.withGroupConfigsOrNull(groupId) { _, _, keys -> + val (envelopBytes, sender) = checkNotNull(keys.decrypt(data)) { + "Failed to decrypt group message" + } + + Log.d(TAG, "Successfully decrypted group message from ${sender.hexString}") + Envelope.parseFrom(envelopBytes) + .toBuilder() + .setSource(sender.hexString) + .build() + } + } + private fun onPush() { Log.d(TAG, "Failed to decode data for message.") val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER) @@ -61,10 +117,13 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) - NotificationManagerCompat.from(context).notify(11111, builder.build()) + + if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + NotificationManagerCompat.from(context).notify(11111, builder.build()) + } } - private fun Map.asByteArray() = + private fun Map.decodeAndDecrypt() = when { // this is a v2 push notification containsKey("spns") -> { @@ -76,18 +135,20 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) } } // old v1 push notification; we still need this for receiving legacy closed group notifications - else -> this["ENCRYPTED_DATA"]?.let(Base64::decode) + else -> this["ENCRYPTED_DATA"]?.let { Base64.decode(it) to null } } - private fun decrypt(encPayload: ByteArray): ByteArray? { + private fun decrypt(encPayload: ByteArray): Pair { Log.d(TAG, "decrypt() called") val encKey = getOrCreateNotificationKey() - val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() - val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() + val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES) + val payload = + encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size) val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) ?: error("Failed to decrypt push notification") - val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray() + val contentEndedAt = padded.indexOfLast { it.toInt() != 0 } + val decrypted = if (contentEndedAt >= 0) padded.sliceArray(0..contentEndedAt) else padded val bencoded = Bencode.Decoder(decrypted) val expectedList = (bencoded.decode() as? BencodeList)?.values ?: error("Failed to decode bencoded list from payload") @@ -99,20 +160,18 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) // null content is valid only if we got a "data_too_long" flag it?.let { check(metadata.data_len == it.size) { "wrong message data size" } } ?: check(metadata.data_too_long) { "missing message data, but no too-long flag" } - } + } to metadata } fun getOrCreateNotificationKey(): Key { - if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) { - // generate the key and store it - val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) - IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) + if (keyHex != null) { + return Key.fromHexString(keyHex) } - return Key.fromHexString( - IdentityKeyUtil.retrieve( - context, - IdentityKeyUtil.NOTIFICATION_KEY - ) - ) + + // generate the key and store it + val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) + IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + return key } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt new file mode 100644 index 00000000000..052d285ff43 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -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() + } + + val userAuth = + storage.userAuth ?: return@combine emptyMap() + getGroupSubscriptions( + token = token, + userSecretKey = userAuth.ed25519PrivateKey + ) + mapOf( + SubscriptionKey(userAuth.accountId, token) to OwnedSubscription( + userAuth, + 0 + ) + ) + } + .scan, Pair, Map>?>( + 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>() + + 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 { + 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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt deleted file mode 100644 index b0954f2327b..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt +++ /dev/null @@ -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 = 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() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index 42ae798366b..67d9e1c342e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -1,117 +1,130 @@ package org.thoughtcrime.securesms.notifications -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.interfaces.Sign -import com.goterl.lazysodium.utils.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromStream -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.map -import okhttp3.MediaType +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request -import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.notifications.Response import org.session.libsession.messaging.sending_receiving.notifications.Server import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.Version +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Device -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace -import org.session.libsignal.utilities.retryIfNeeded +import org.session.libsignal.utilities.retryWithUniformInterval import javax.inject.Inject import javax.inject.Singleton -private val TAG = PushRegistryV2::class.java.name private const val maxRetryCount = 4 @Singleton -class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver) { - fun register( - device: Device, +class PushRegistryV2 @Inject constructor( + private val pushReceiver: PushReceiver, + private val device: Device, + ) { + suspend fun register( token: String, - publicKey: String, - userEd25519Key: KeyPair, + swarmAuth: SwarmAuth, namespaces: List - ): Promise { + ) { val pnKey = pushReceiver.getOrCreateNotificationKey() val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s - // if we want to support passing namespace list, here is the place to do it - val sigData = "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray() - val signature = ByteArray(Sign.BYTES) - sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEd25519Key.secretKey.asBytes) + val publicKey = swarmAuth.accountId.hexString + val signed = swarmAuth.sign( + "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray() + ) val requestParameters = SubscriptionRequest( pubkey = publicKey, - session_ed25519 = userEd25519Key.publicKey.asHexString, - namespaces = listOf(Namespace.DEFAULT), + session_ed25519 = swarmAuth.ed25519PublicKeyHex, + namespaces = namespaces, data = true, // only permit data subscription for now (?) service = device.service, sig_ts = timestamp, - signature = Base64.encodeBytes(signature), service_info = mapOf("token" to token), enc_key = pnKey.asHexString, - ).let(Json::encodeToString) + ).let(Json::encodeToJsonElement).jsonObject + signed + + val response = retryResponseBody( + "subscribe", + Json.encodeToString(requestParameters) + ) - return retryResponseBody("subscribe", requestParameters) success { - Log.d(TAG, "registerV2 success") + check(response.isSuccess()) { + "Error subscribing to push notifications: ${response.message}" } } - fun unregister( - device: Device, + suspend fun unregister( token: String, - userPublicKey: String, - userEdKey: KeyPair - ): Promise { + swarmAuth: SwarmAuth + ) { + val publicKey = swarmAuth.accountId.hexString val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s // if we want to support passing namespace list, here is the place to do it - val sigData = "UNSUBSCRIBE${userPublicKey}${timestamp}".encodeToByteArray() - val signature = ByteArray(Sign.BYTES) - sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEdKey.secretKey.asBytes) + val signature = swarmAuth.signForPushRegistry( + "UNSUBSCRIBE${publicKey}${timestamp}".encodeToByteArray() + ) val requestParameters = UnsubscriptionRequest( - pubkey = userPublicKey, - session_ed25519 = userEdKey.publicKey.asHexString, + pubkey = publicKey, + session_ed25519 = swarmAuth.ed25519PublicKeyHex, service = device.service, sig_ts = timestamp, - signature = Base64.encodeBytes(signature), service_info = mapOf("token" to token), - ).let(Json::encodeToString) + ).let(Json::encodeToJsonElement).jsonObject + signature - return retryResponseBody("unsubscribe", requestParameters) success { - Log.d(TAG, "unregisterV2 success") + val response: UnsubscribeResponse = retryResponseBody("unsubscribe", Json.encodeToString(requestParameters)) + + check(response.isSuccess()) { + "Error unsubscribing to push notifications: ${response.message}" } } - private inline fun retryResponseBody(path: String, requestParameters: String): Promise = - retryIfNeeded(maxRetryCount) { getResponseBody(path, requestParameters) } + private operator fun JsonObject.plus(additional: Map): JsonObject { + return JsonObject(buildMap { + putAll(this@plus) + for ((key, value) in additional) { + put(key, JsonPrimitive(value)) + } + }) + } + + private suspend inline fun retryResponseBody(path: String, requestParameters: String): T = + retryWithUniformInterval(maxRetryCount = maxRetryCount) { getResponseBody(path, requestParameters) } - private inline fun getResponseBody(path: String, requestParameters: String): Promise { + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun getResponseBody(path: String, requestParameters: String): T { val server = Server.LATEST val url = "${server.url}/$path" - val body = RequestBody.create("application/json".toMediaType(), requestParameters) + val body = requestParameters.toRequestBody("application/json".toMediaType()) val request = Request.Builder().url(url).post(body).build() + val response = OnionRequestAPI.sendOnionRequest( + request = request, + server = server.url, + x25519PublicKey = server.publicKey, + version = Version.V4 + ).await() - return OnionRequestAPI.sendOnionRequest( - request, - server.url, - server.publicKey, - Version.V4 - ).map { response -> - response.body!!.inputStream() - .let { Json.decodeFromStream(it) } - .also { if (it.isFailure()) throw Exception("error: ${it.message}.") } + return withContext(Dispatchers.IO) { + requireNotNull(response.body) { "Response doesn't have a body" } + .inputStream() + .use { Json.decodeFromStream(it) } } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt deleted file mode 100644 index 5bd9ce0d8d7..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -interface TokenFetcher { - suspend fun fetch(): String? -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt deleted file mode 100644 index b3db642b812..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import org.session.libsession.utilities.TextSecurePreferences -import javax.inject.Inject -import javax.inject.Singleton - -private const val INTERVAL: Int = 12 * 60 * 60 * 1000 - -@Singleton -class TokenManager @Inject constructor( - @ApplicationContext private val context: Context, -) { - val hasValidRegistration get() = isRegistered && !isExpired - val isRegistered get() = time > 0 - private val isExpired get() = currentTime() > time + INTERVAL - - fun register() { - time = currentTime() - } - - fun unregister() { - time = 0 - } - - private var time - get() = TextSecurePreferences.getPushRegisterTime(context) - set(value) = TextSecurePreferences.setPushRegisterTime(context, value) - - private fun currentTime() = System.currentTimeMillis() -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index c87d5fc568f..66614366fe5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -27,13 +27,13 @@ import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan) -@OptIn(ExperimentalFoundationApi::class) @Composable internal fun LoadAccountScreen( state: State, @@ -97,10 +97,11 @@ private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onCo style = LocalType.current.base ) Spacer(Modifier.height(LocalDimensions.current.spacing)) + SessionOutlinedTextField( text = state.recoveryPhrase, - modifier = Modifier.fillMaxWidth(), - contentDescription = stringResource(R.string.AccessibilityId_recoveryPasswordEnter), + modifier = Modifier.fillMaxWidth() + .contentDescription(R.string.AccessibilityId_recoveryPasswordEnter), placeholder = stringResource(R.string.recoveryPasswordEnter), onChange = onChange, onContinue = onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt index f1f5bee89f0..a220e9d60fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt @@ -16,14 +16,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.onboarding.manager.CreateAccountManager internal class MessageNotificationsViewModel( private val state: State, private val application: Application, private val prefs: TextSecurePreferences, - private val pushRegistry: PushRegistry, private val createAccountManager: CreateAccountManager ): AndroidViewModel(application) { private val _uiStates = MutableStateFlow(UiState()) @@ -41,7 +39,6 @@ internal class MessageNotificationsViewModel( if (state is State.CreateAccount) createAccountManager.createAccount(state.displayName) prefs.setPushEnabled(uiStates.value.pushEnabled) - pushRegistry.refresh(true) _events.emit( when (state) { @@ -99,7 +96,6 @@ internal class MessageNotificationsViewModel( @Assisted private val profileName: String?, private val application: Application, private val prefs: TextSecurePreferences, - private val pushRegistry: PushRegistry, private val createAccountManager: CreateAccountManager, ) : ViewModelProvider.Factory { @@ -108,7 +104,6 @@ internal class MessageNotificationsViewModel( state = profileName?.let(State::CreateAccount) ?: State.LoadAccount, application = application, prefs = prefs, - pushRegistry = pushRegistry, createAccountManager = createAccountManager ) as T } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt index 1481695a3a5..c04408645cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.theme.LocalType @Preview @@ -63,10 +64,10 @@ internal fun PickDisplayName( style = LocalType.current.base, modifier = Modifier.padding(bottom = LocalDimensions.current.xsSpacing)) Spacer(Modifier.height(LocalDimensions.current.spacing)) + SessionOutlinedTextField( text = state.displayName, - modifier = Modifier.fillMaxWidth(), - contentDescription = stringResource(R.string.AccessibilityId_displayNameEnter), + modifier = Modifier.fillMaxWidth().contentDescription(R.string.AccessibilityId_displayNameEnter), placeholder = stringResource(R.string.displayNameEnter), onChange = onChange, onContinue = onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index ae9dfe47608..612cb69af7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.Storage @@ -28,7 +29,7 @@ import org.thoughtcrime.securesms.util.adapter.SelectableItem import javax.inject.Inject @HiltViewModel -class BlockedContactsViewModel @Inject constructor(private val storage: Storage): ViewModel() { +class BlockedContactsViewModel @Inject constructor(private val storage: StorageProtocol): ViewModel() { private val executor = viewModelScope + SupervisorJob() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 98ad62dcb3f..fe2f94e0935 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -9,6 +9,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job @@ -16,19 +17,27 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.DialogClearAllDataBinding +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject +@AndroidEntryPoint class ClearAllDataDialog : DialogFragment() { private val TAG = "ClearAllDataDialog" private lateinit var binding: DialogClearAllDataBinding + @Inject + lateinit var storage: StorageProtocol + private enum class Steps { INFO_PROMPT, NETWORK_PROMPT, @@ -116,7 +125,7 @@ class ClearAllDataDialog : DialogFragment() { private suspend fun performDeleteLocalDataOnlyStep() { try { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()) } catch (e: Exception) { Log.e(TAG, "Failed to force sync when deleting data", e) withContext(Main) { @@ -149,7 +158,7 @@ class ClearAllDataDialog : DialogFragment() { openGroups.map { it.value.server }.toSet().forEach { server -> OpenGroupApi.deleteAllInboxMessages(server).get() } - SnodeAPI.deleteAllMessages().get() + SnodeAPI.deleteAllMessages(checkNotNull(storage.userAuth)).get() } catch (e: Exception) { Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) null diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt index 5614091473d..1f273a43bdb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt @@ -10,26 +10,19 @@ import android.os.AsyncTask import android.os.Bundle import android.provider.Settings import android.text.TextUtils -import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.components.SwitchPreferenceCompat import org.thoughtcrime.securesms.notifications.NotificationChannels -import org.thoughtcrime.securesms.notifications.PushRegistry import javax.inject.Inject @AndroidEntryPoint class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { - @Inject - lateinit var pushRegistry: PushRegistry @Inject lateinit var prefs: TextSecurePreferences @@ -39,21 +32,9 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { // Set up FCM toggle val fcmKey = "pref_key_use_fcm" val fcmPreference: SwitchPreferenceCompat = findPreference(fcmKey)!! - fcmPreference.isChecked = prefs.isPushEnabled() + fcmPreference.isChecked = prefs.pushEnabled.value fcmPreference.setOnPreferenceChangeListener { _: Preference, newValue: Any -> prefs.setPushEnabled(newValue as Boolean) - val job = pushRegistry.refresh(true) - - fcmPreference.isEnabled = false - - lifecycleScope.launch(Dispatchers.IO) { - job.join() - - withContext(Dispatchers.Main) { - fcmPreference.isEnabled = true - } - } - true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java index 1c483a43e77..14d4f75d997 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java @@ -1,17 +1,18 @@ package org.thoughtcrime.securesms.preferences.widgets; import android.content.Context; -import android.util.AttributeSet; -import android.widget.TextView; import androidx.preference.ListPreference; import androidx.preference.PreferenceViewHolder; +import android.util.AttributeSet; +import android.widget.TextView; import network.loki.messenger.R; public class SignalListPreference extends ListPreference { - private TextView rightSummary; - private CharSequence summary; - private OnPreferenceClickListener clickListener; + private TextView rightSummaryTV; + private CharSequence summary; + private OnPreferenceClickListener clickListener; + private CharSequence summarySpecifiedInLayoutXML; public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); @@ -23,48 +24,51 @@ public SignalListPreference(Context context, AttributeSet attrs, int defStyleAtt initialize(); } - public SignalListPreference(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public SignalListPreference(Context context) { - super(context); - initialize(); - } - - private void initialize() { - setWidgetLayoutResource(R.layout.preference_right_summary_widget); - } + public SignalListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } - @Override - public void onBindViewHolder(PreferenceViewHolder view) { - super.onBindViewHolder(view); + public SignalListPreference(Context context) { + super(context); + initialize(); + } - this.rightSummary = (TextView)view.findViewById(R.id.right_summary); - setSummary(this.summary); - } + private void initialize() { + summarySpecifiedInLayoutXML = this.getSummary(); + if (summarySpecifiedInLayoutXML == null) { summarySpecifiedInLayoutXML = ""; } + setWidgetLayoutResource(R.layout.preference_right_summary_widget); + } - @Override - public void setSummary(CharSequence summary) { - super.setSummary(null); + @Override + public void onBindViewHolder(PreferenceViewHolder view) { + super.onBindViewHolder(view); + this.rightSummaryTV = (TextView)view.findViewById(R.id.right_summary); + setSummary(this.summary); + } - this.summary = summary; + @Override + public void setSummary(CharSequence incomingSummary) { + // Set the left "subtitle" summary such as "The information shown in notifications." etc. + super.setSummary(summarySpecifiedInLayoutXML); - if (this.rightSummary != null) { - this.rightSummary.setText(summary); + // Then set the right summary to be the incoming drop-down selected option + this.summary = incomingSummary; + if (this.rightSummaryTV != null) { + this.rightSummaryTV.setText(incomingSummary); + } } - } - @Override - public void setOnPreferenceClickListener(OnPreferenceClickListener onPreferenceClickListener) { - this.clickListener = onPreferenceClickListener; - } + @Override + public void setOnPreferenceClickListener (OnPreferenceClickListener + onPreferenceClickListener){ + this.clickListener = onPreferenceClickListener; + } - @Override - protected void onClick() { - if (clickListener == null || !clickListener.onPreferenceClick(this)) { - super.onClick(); + @Override + protected void onClick () { + if (clickListener == null || !clickListener.onPreferenceClick(this)) { + super.onClick(); + } } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index 37bd7e4695e..dd9f17281bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -9,6 +9,8 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; + +import org.session.libsignal.utilities.AccountId; import java.util.Collections; import java.util.List; import network.loki.messenger.R; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt index 62b602f6f34..ab304e60de9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt @@ -46,7 +46,7 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { cancelButton() dangerButton( R.string.yes, - contentDescription = R.string.AccessibilityId_recoveryPasswordHidePermanentlyConfirm + contentDescriptionRes = R.string.AccessibilityId_recoveryPasswordHidePermanentlyConfirm ) { viewModel.permanentlyHidePassword() finish() diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 7f10b1eb20d..93c60d32f48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,17 +1,17 @@ package org.thoughtcrime.securesms.repository -import network.loki.messenger.libsession_util.util.ExpiryMode - import android.content.ContentResolver import android.content.Context import app.cash.copper.Query import app.cash.copper.flow.observeQuery import dagger.hilt.android.qualifiers.ApplicationContext -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.UnsendRequest @@ -21,6 +21,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences @@ -54,12 +55,13 @@ interface ConversationRepository { fun getDraft(threadId: Long): String? fun clearDrafts(threadId: Long) fun inviteContacts(threadId: Long, contacts: List) - fun setBlocked(recipient: Recipient, blocked: Boolean) + fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean) fun deleteLocally(recipient: Recipient, message: MessageRecord) fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) + fun isKicked(recipient: Recipient): Boolean + suspend fun deleteForEveryone(threadId: Long, recipient: Recipient, message: MessageRecord): Result - fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? suspend fun deleteMessageWithoutUnsendRequest(threadId: Long, messages: Set): Result suspend fun banUser(threadId: Long, recipient: Recipient): Result suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result @@ -67,8 +69,9 @@ interface ConversationRepository { suspend fun deleteMessageRequest(thread: ThreadRecord): Result suspend fun clearAllMessageRequests(block: Boolean): Result suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): Result - fun declineMessageRequest(threadId: Long) + fun declineMessageRequest(threadId: Long, recipient: Recipient) fun hasReceived(threadId: Long): Boolean + fun getInvitingAdmin(threadId: Long): Recipient? } class DefaultConversationRepository @Inject constructor( @@ -153,17 +156,30 @@ class DefaultConversationRepository @Inject constructor( } } + override fun isKicked(recipient: Recipient): Boolean { + // For now, we only know care we are kicked for a groups v2 recipient + if (!recipient.isClosedGroupV2Recipient) { + return false + } + + return configFactory.userGroups + ?.getClosedGroup(recipient.address.serialize())?.kicked == true + } + // This assumes that recipient.isContactRecipient is true - override fun setBlocked(recipient: Recipient, blocked: Boolean) { - storage.setBlocked(listOf(recipient), blocked) + override fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean) { + if (recipient.isContactRecipient) { + storage.setBlocked(listOf(recipient), blocked) + } } override fun deleteLocally(recipient: Recipient, message: MessageRecord) { - buildUnsendRequest(recipient, message)?.let { unsendRequest -> + if (shouldSendUnsendRequest(recipient)) { textSecurePreferences.getLocalNumber()?.let { - MessageSender.send(unsendRequest, Address.fromSerialized(it)) + MessageSender.send(buildUnsendRequest(message), Address.fromSerialized(it)) } } + messageDataProvider.deleteMessage(message.id, !message.isMms) } @@ -184,62 +200,79 @@ class DefaultConversationRepository @Inject constructor( threadId: Long, recipient: Recipient, message: MessageRecord - ): Result = suspendCoroutine { continuation -> - buildUnsendRequest(recipient, message)?.let { unsendRequest -> - MessageSender.send(unsendRequest, recipient.address) - } - - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - if (openGroup != null) { - val serverId = lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> - OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) - .success { + ): Result { + return runCatching { + withContext(Dispatchers.Default) { + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) + if (openGroup != null) { + val serverId = lokiMessageDb.getServerID(message.id, !message.isMms) + if (serverId != null) { + OpenGroupApi.deleteMessage( + serverID = serverId, + room = openGroup.room, + server = openGroup.server + ).await() messageDataProvider.deleteMessage(message.id, !message.isMms) - continuation.resume(Result.success(Unit)) - }.fail { error -> - Log.w("TAG", "Call to OpenGroupApi.deleteForEveryone failed - attempting to resume..") - continuation.resume(Result.failure(error)) + } else { + // If the server ID is null then this message is stuck in limbo (it has likely been + // deleted remotely but that deletion did not occur locally) - so we'll delete the + // message locally to clean up. + Log.w( + "ConversationRepository", + "Found community message without a server ID - deleting locally." + ) + + // Caution: The bool returned from `deleteMessage` is NOT "Was the message + // successfully deleted?" - it is "Was the thread itself also deleted because + // removing that message resulted in an empty thread?". + if (message.isMms) { + mmsDb.deleteMessage(message.id) + } else { + smsDb.deleteMessage(message.id) + } } - } - - // If the server ID is null then this message is stuck in limbo (it has likely been - // deleted remotely but that deletion did not occur locally) - so we'll delete the - // message locally to clean up. - if (serverId == null) { - Log.w("ConversationRepository","Found community message without a server ID - deleting locally.") - - // Caution: The bool returned from `deleteMessage` is NOT "Was the message - // successfully deleted?" - it is "Was the thread itself also deleted because - // removing that message resulted in an empty thread?". - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) - } - } - } - else // If this thread is NOT in a Community - { - messageDataProvider.deleteMessage(message.id, !message.isMms) - messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash -> - var publicKey = recipient.address.serialize() - if (recipient.isClosedGroupRecipient) { - publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() - } - SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) - .success { - continuation.resume(Result.success(Unit)) - }.fail { error -> - Log.w("ConversationRepository", "Call to SnodeAPI.deleteMessage failed - attempting to resume..") - continuation.resume(Result.failure(error)) + } else // If this thread is NOT in a Community + { + val serverHash = + messageDataProvider.getServerHashForMessage(message.id, message.isMms) + if (serverHash != null) { + var publicKey = recipient.address.serialize() + if (recipient.isLegacyClosedGroupRecipient) { + publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() + } + + if (recipient.isClosedGroupV2Recipient) { + // admin check internally, assume either admin or all belong to user + storage.sendGroupUpdateDeleteMessage( + groupSessionId = recipient.address.serialize(), + messageHashes = listOf(serverHash) + ).await() + } else { + SnodeAPI.deleteMessage( + publicKey = publicKey, + swarmAuth = storage.userAuth!!, + serverHashes = listOf(serverHash) + ).await() + } + + if (shouldSendUnsendRequest(recipient)) { + MessageSender.send( + message = buildUnsendRequest(message), + address = recipient.address, + ) + } } + messageDataProvider.deleteMessage(message.id, !message.isMms) + } } } } - override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { - if (recipient.isCommunityRecipient) return null - messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null + private fun shouldSendUnsendRequest(recipient: Recipient): Boolean { + return recipient.is1on1 || recipient.isLegacyClosedGroupRecipient + } + + private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { return UnsendRequest( author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), timestamp = message.timestamp @@ -249,99 +282,96 @@ class DefaultConversationRepository @Inject constructor( override suspend fun deleteMessageWithoutUnsendRequest( threadId: Long, messages: Set - ): Result = suspendCoroutine { continuation -> - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - if (openGroup != null) { - val messageServerIDs = mutableMapOf() - for (message in messages) { - val messageServerID = - lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue - messageServerIDs[messageServerID] = message - } - messageServerIDs.forEach { (messageServerID, message) -> - OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) - .success { - messageDataProvider.deleteMessage(message.id, !message.isMms) - }.fail { error -> - continuation.resume(Result.failure(error)) + ): Result = kotlin.runCatching { + withContext(Dispatchers.Default) { + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) + if (openGroup != null) { + val messageServerIDs = mutableMapOf() + for (message in messages) { + val messageServerID = + lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue + messageServerIDs[messageServerID] = message + } + messageServerIDs.forEach { (messageServerID, message) -> + OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server).await() + messageDataProvider.deleteMessage(message.id, !message.isMms) + } + } else { + for (message in messages) { + if (message.isMms) { + mmsDb.deleteMessage(message.id) + } else { + smsDb.deleteMessage(message.id) } - } - } else { - for (message in messages) { - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) } } } - continuation.resume(Result.success(Unit)) } - override suspend fun banUser(threadId: Long, recipient: Recipient): Result = - suspendCoroutine { continuation -> - val accountID = recipient.address.toString() - val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupApi.ban(accountID, openGroup.room, openGroup.server) - .success { - continuation.resume(Result.success(Unit)) - }.fail { error -> - continuation.resume(Result.failure(error)) - } - } + override suspend fun banUser(threadId: Long, recipient: Recipient): Result = runCatching { + val accountID = recipient.address.toString() + val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! + OpenGroupApi.ban(accountID, openGroup.room, openGroup.server).await() + } - override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result = - suspendCoroutine { continuation -> - // Note: This accountId could be the blinded Id - val accountID = recipient.address.toString() - val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - - OpenGroupApi.banAndDeleteAll(accountID, openGroup.room, openGroup.server) - .success { - continuation.resume(Result.success(Unit)) - }.fail { error -> - continuation.resume(Result.failure(error)) - } - } + override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient) = runCatching { + // Note: This accountId could be the blinded Id + val accountID = recipient.address.toString() + val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - override suspend fun deleteThread(threadId: Long): Result { - sessionJobDb.cancelPendingMessageSendJobs(threadId) - storage.deleteConversation(threadId) - return Result.success(Unit) + OpenGroupApi.banAndDeleteAll(accountID, openGroup.room, openGroup.server).await() } - override suspend fun deleteMessageRequest(thread: ThreadRecord): Result { - sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) - storage.deleteConversation(thread.threadId) - return Result.success(Unit) + override suspend fun deleteThread(threadId: Long) = runCatching { + withContext(Dispatchers.Default) { + sessionJobDb.cancelPendingMessageSendJobs(threadId) + storage.deleteConversation(threadId) + } } - override suspend fun clearAllMessageRequests(block: Boolean): Result { - threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> - while (reader.next != null) { - deleteMessageRequest(reader.current) - val recipient = reader.current.recipient - if (block) { setBlocked(recipient, true) } + override suspend fun deleteMessageRequest(thread: ThreadRecord) = runCatching { + withContext(Dispatchers.Default) { + declineMessageRequest(thread.threadId, thread.recipient) + } + } + + override suspend fun clearAllMessageRequests(block: Boolean) = runCatching { + withContext(Dispatchers.Default) { + threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> + while (reader.next != null) { + deleteMessageRequest(reader.current) + val recipient = reader.current.recipient + if (block && !recipient.isClosedGroupV2Recipient) { + setBlocked(reader.current.threadId, recipient, true) + } + } } } - return Result.success(Unit) } - override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): Result = suspendCoroutine { continuation -> - storage.setRecipientApproved(recipient, true) - val message = MessageRequestResponse(true) - MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) - .success { - threadDb.setHasSent(threadId, true) - continuation.resume(Result.success(Unit)) - }.fail { error -> - continuation.resume(Result.failure(error)) + override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient) = runCatching { + withContext(Dispatchers.Default) { + storage.setRecipientApproved(recipient, true) + if (recipient.isClosedGroupV2Recipient) { + storage.respondToClosedGroupInvitation(threadId, recipient, true) + } else { + val message = MessageRequestResponse(true) + MessageSender.send( + message = message, + destination = Destination.from(recipient.address), + isSyncMessage = recipient.isLocalNumber + ).await() } + } } - override fun declineMessageRequest(threadId: Long) { + override fun declineMessageRequest(threadId: Long, recipient: Recipient) { sessionJobDb.cancelPendingMessageSendJobs(threadId) - storage.deleteConversation(threadId) + if (recipient.isClosedGroupV2Recipient) { + storage.respondToClosedGroupInvitation(threadId, recipient, false) + } else { + storage.deleteConversation(threadId) + } } override fun hasReceived(threadId: Long): Boolean { @@ -354,4 +384,10 @@ class DefaultConversationRepository @Inject constructor( return false } + // Only call this with a closed group thread ID + override fun getInvitingAdmin(threadId: Long): Recipient? { + return lokiMessageDb.groupInviteReferrer(threadId)?.let { id -> + Recipient.from(context, Address.fromSerialized(id), false) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 2f6ad7fd8b6..c1795f8f0cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -8,13 +8,16 @@ import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID -import org.session.libsession.utilities.GroupUtil.getDecodedGroupIDAsData import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.database.MmsDatabase @@ -77,14 +80,18 @@ class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtoco if (recipient.isBlocked && groupId == null) return try { if (groupId != null) { - val groupID = doubleEncodeGroupID(groupId) - groupInfo = Optional.of( - SignalServiceGroup( - getDecodedGroupIDAsData(groupID), - SignalServiceGroup.GroupType.SIGNAL - ) - ) - val groupAddress = fromSerialized(groupID) + val groupAddress: Address + groupInfo = when { + groupId.startsWith(IdPrefix.GROUP.value) -> { + groupAddress = fromSerialized(groupId) + Optional.of(SignalServiceGroup(Hex.fromStringCondensed(groupId), SignalServiceGroup.GroupType.SIGNAL)) + } + else -> { + val doubleEncoded = GroupUtil.doubleEncodeGroupID(groupId) + groupAddress = fromSerialized(doubleEncoded) + Optional.of(SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(doubleEncoded), SignalServiceGroup.GroupType.SIGNAL)) + } + } recipient = Recipient.from(context, groupAddress, false) } val threadId = shared.storage.getThreadId(recipient) ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index d6383ab7fcd..ac873d0a3bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -5,10 +5,10 @@ import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob -import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index baf0c1f1223..180ec6fd072 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -8,11 +8,16 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -23,6 +28,9 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ButtonColors import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider @@ -33,6 +41,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,7 +50,9 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer @@ -51,9 +62,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.google.accompanist.drawablepainter.rememberDrawablePainter import kotlinx.coroutines.CoroutineScope @@ -78,7 +93,7 @@ interface Callbacks { fun setValue(value: T) } -object NoOpCallbacks: Callbacks { +object NoOpCallbacks : Callbacks { override fun onSetClick() {} override fun setValue(value: Any) {} } @@ -147,7 +162,12 @@ fun ItemButtonWithDrawable( modifier = modifier, icon = { Image( - painter = rememberDrawablePainter(drawable = AppCompatResources.getDrawable(context, icon)), + painter = rememberDrawablePainter( + drawable = AppCompatResources.getDrawable( + context, + icon + ) + ), contentDescription = null, modifier = Modifier.align(Alignment.Center) ) @@ -240,10 +260,10 @@ fun ItemButton( } /** -* Base [ItemButton] implementation. + * Base [ItemButton] implementation. * * A button to be used in a list of buttons, usually in a [Cell] or [Card] -*/ + */ @Composable fun ItemButton( text: String, @@ -371,7 +391,8 @@ fun Modifier.fadingEdges( @Composable fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { HorizontalDivider( - modifier = modifier.padding(horizontal = LocalDimensions.current.smallSpacing) + modifier = modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) .padding(start = startIndent), color = LocalColors.current.borders, ) @@ -472,3 +493,199 @@ fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { content() } } + + +@Composable +fun SearchBar( + query: String, + onValueChanged: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + backgroundColor: Color = LocalColors.current.background +) { + BasicTextField( + singleLine = true, + value = query, + onValueChange = onValueChanged, + enabled = enabled, + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(100)) + ) { + Image( + painterResource(id = R.drawable.ic_search_24), + contentDescription = null, + colorFilter = ColorFilter.tint( + LocalColors.current.text + ), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + + Box(modifier = Modifier.weight(1f)) { + innerTextField() + if (query.isEmpty() && placeholder != null) { + Text( + text = placeholder, + color = LocalColors.current.textSecondary, + style = LocalType.current.base + ) + } + } + } + }, + textStyle = LocalType.current.base.copy(color = LocalColors.current.text), + modifier = modifier, + cursorBrush = SolidColor(LocalColors.current.text) + ) +} + +@Composable +fun NavigationBar( + title: String, + titleAlignment: Alignment = Alignment.Center, + onBack: (() -> Unit)? = null, + actionElement: (@Composable BoxScope.() -> Unit)? = null +) { + Row( + Modifier + .fillMaxWidth() + .height(64.dp) + ) { + // Optional back button, layout should still take up space + Box( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1.0f, true) + .padding(16.dp) + ) { + if (onBack != null) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back_24), + contentDescription = stringResource( + id = R.string.AccessibilityId_navigateBack + ), + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 16.dp), + ) { onBack() } + .align(Alignment.Center) + ) + } + } + //Main title + Box( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(8.dp) + ) { + Text( + text = title, + Modifier.align(titleAlignment), + overflow = TextOverflow.Ellipsis, + fontSize = 26.sp, + fontWeight = FontWeight.Bold + ) + } + // Optional action + if (actionElement != null) { + Box( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterVertically) + .aspectRatio(1.0f, true), + contentAlignment = Alignment.Center + ) { + actionElement(this) + } + } + } +} + +@Composable +fun BoxScope.CloseIcon(onClose: () -> Unit) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_close_24), + contentDescription = stringResource( + id = R.string.close + ), + Modifier + .clickable { onClose() } + .align(Alignment.Center) + .padding(16.dp) + ) +} + +@Composable +fun RowScope.WeightedOptionButton( + modifier: Modifier = Modifier, + @StringRes label: Int, + destructive: Boolean = false, + weight: Float = 1f, + onClick: () -> Unit +) { + Text( + text = stringResource(label), + modifier = modifier + .padding(16.dp) + .weight(weight) + .clickable { + onClick() + }, + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = if (destructive) LocalColors.current.danger else Color.Unspecified + ) +} + +@Preview +@Composable +fun PreviewWeightedOptionButtons() { + Column(modifier = Modifier.fillMaxWidth()) { + // two equal sized + Row(modifier = Modifier.fillMaxWidth()) { + WeightedOptionButton(label = R.string.ok) { + + } + WeightedOptionButton(label = R.string.cancel, destructive = true) { + + } + } + // single left justified + Row(modifier = Modifier.fillMaxWidth()) { + WeightedOptionButton(label = R.string.cancel, destructive = true, weight = 1f) { + + } + // press F to pay respects to `android:weightSum` + Box(Modifier.weight(1f)) + } + } +} + + +@Composable +@Preview +fun PreviewNavigationBar() { + NavigationBar(title = "Create Group", onBack = {}, actionElement = { + CloseIcon {} + }) +} + +@Composable +@Preview +fun PreviewSearchBar() { + PreviewTheme { + Column(Modifier.background(LocalColors.current.backgroundSecondary)) { + SearchBar("Search query", {}) + SearchBar("", {}, placeholder = "Hint text") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index 70b6634cb89..b2b40bbc6f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -55,8 +55,8 @@ import com.google.zxing.PlanarYUVLuminanceSource import com.google.zxing.Result import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeReader -import com.squareup.phrase.Phrase import java.util.concurrent.Executors +import com.squareup.phrase.Phrase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import network.loki.messenger.R diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 58e1ff8533a..597302edf65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.ui.components import androidx.annotation.DrawableRes import androidx.compose.animation.animateContentSize import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.appendInlineContent @@ -21,6 +24,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -51,8 +55,10 @@ import org.thoughtcrime.securesms.ui.theme.bold @Composable fun PreviewSessionOutlinedTextField() { PreviewTheme { - Column(modifier = Modifier.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { SessionOutlinedTextField( text = "text", placeholder = "", @@ -83,64 +89,66 @@ fun PreviewSessionOutlinedTextField() { fun SessionOutlinedTextField( text: String, modifier: Modifier = Modifier, - contentDescription: String? = null, onChange: (String) -> Unit = {}, textStyle: TextStyle = LocalType.current.base, placeholder: String = "", onContinue: () -> Unit = {}, error: String? = null, - isTextErrorColor: Boolean = error != null + isTextErrorColor: Boolean = error != null, + enabled: Boolean = true, ) { - Column(modifier = modifier.animateContentSize()) { - Box( - modifier = Modifier.border( - width = LocalDimensions.current.borderStroke, - color = LocalColors.current.borders(error != null), - shape = MaterialTheme.shapes.small - ) - .fillMaxWidth() - .wrapContentHeight() - .padding(vertical = 28.dp) - .padding(horizontal = 21.dp) - ) { - if (text.isEmpty()) { - Text( - text = placeholder, - style = LocalType.current.base, - color = LocalColors.current.textSecondary(isTextErrorColor), - modifier = Modifier.wrapContentSize() - .align(Alignment.CenterStart) - .wrapContentSize() - ) - } + BasicTextField( + value = text, + onValueChange = onChange, + modifier = modifier, + textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), + cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), + enabled = enabled, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { onContinue() }, + onGo = { onContinue() }, + onSearch = { onContinue() }, + onSend = { onContinue() }, + ), + decorationBox = { innerTextField -> + Column(modifier = Modifier.animateContentSize()) { + Box( + modifier = Modifier + .border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.borders(error != null), + shape = MaterialTheme.shapes.small + ) + .fillMaxWidth() + .wrapContentHeight() + .padding(vertical = 28.dp, horizontal = 21.dp) + ) { + innerTextField() - BasicTextField( - value = text, - onValueChange = onChange, - modifier = Modifier.wrapContentHeight().fillMaxWidth().contentDescription(contentDescription), - textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), - cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { onContinue() }, - onGo = { onContinue() }, - onSearch = { onContinue() }, - onSend = { onContinue() }, - ) - ) - } - error?.let { - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - Text( - it, - modifier = Modifier.fillMaxWidth() - .contentDescription(R.string.AccessibilityId_theError), - textAlign = TextAlign.Center, - style = LocalType.current.base.bold(), - color = LocalColors.current.danger - ) + if (placeholder.isNotEmpty() && text.isEmpty()) { + Text( + text = placeholder, + style = textStyle.copy(color = LocalColors.current.textSecondary), + ) + } + } + + error?.let { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Text( + it, + modifier = Modifier + .fillMaxWidth() + .contentDescription(R.string.AccessibilityId_theError), + textAlign = TextAlign.Center, + style = LocalType.current.base.bold(), + color = LocalColors.current.danger + ) + } + } } - } + ) } @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Colors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Colors.kt index 0b630ab233c..4ef4601138b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Colors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Colors.kt @@ -49,4 +49,6 @@ val dangerLight = Color(0xFFE12D19) val disabledDark = Color(0xFFA1A2A1) val disabledLight = Color(0xFF6D6D6D) +val warningUniversal = Color(0xFFFCB159) + val blackAlpha40 = Color.Black.copy(alpha = 0.4f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index 252497f0230..051344ae6ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -18,6 +18,8 @@ interface ThemeColors { // properties to override for each theme val isLight: Boolean val primary: Color + val warning: Color + val textAlert: Color val danger: Color val disabled: Color val background: Color @@ -100,6 +102,7 @@ fun dangerButtonColors() = ButtonDefaults.buttonColors( data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = false override val danger = dangerDark + override val warning = warningUniversal override val disabled = disabledDark override val background = classicDark0 override val backgroundSecondary = classicDark1 @@ -113,11 +116,13 @@ data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors override val qrCodeBackground = text override val primaryButtonFill = primary override val primaryButtonFillText = Color.Black + override val textAlert: Color = classicDark6 } data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = true override val danger = dangerLight + override val warning = warningUniversal override val disabled = disabledLight override val background = classicLight6 override val backgroundSecondary = classicLight5 @@ -131,11 +136,13 @@ data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColor override val qrCodeBackground = backgroundSecondary override val primaryButtonFill = text override val primaryButtonFillText = Color.White + override val textAlert: Color = classicLight0 } data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { override val isLight = false override val danger = dangerDark + override val warning = warningUniversal override val disabled = disabledDark override val background = oceanDark2 override val backgroundSecondary = oceanDark1 @@ -149,11 +156,13 @@ data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { override val qrCodeBackground = text override val primaryButtonFill = primary override val primaryButtonFillText = Color.Black + override val textAlert: Color = oceanDark7 } data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { override val isLight = true override val danger = dangerLight + override val warning = warningUniversal override val disabled = disabledLight override val background = oceanLight7 override val backgroundSecondary = oceanLight6 @@ -167,6 +176,7 @@ data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { override val qrCodeBackground = backgroundSecondary override val primaryButtonFill = text override val primaryButtonFillText = Color.White + override val textAlert: Color = oceanLight0 } @Preview diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt new file mode 100644 index 00000000000..398bcbcc04f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.util + +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment + +private const val ZERO_SIZE = "0.00" +private const val KILO_SIZE = 1024f +private const val MB_SUFFIX = "MB" +private const val KB_SUFFIX = "KB" + +fun Attachment.displaySize(): String { + + val kbSize = size / KILO_SIZE + val needsMb = kbSize > KILO_SIZE + val sizeText = "%.2f".format(if (needsMb) kbSize / KILO_SIZE else kbSize) + val displaySize = when { + sizeText == ZERO_SIZE -> "0.01" + sizeText.endsWith(".00") -> sizeText.takeWhile { it != '.' } + else -> sizeText + } + return "$displaySize${if (needsMb) MB_SUFFIX else KB_SUFFIX}" +} + +fun JobQueue.createAndStartAttachmentDownload(attachment: DatabaseAttachment) { + val attachmentId = attachment.attachmentId.rowId + if (attachment.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + // start download + add(AttachmentDownloadJob(attachmentId, attachment.mmsId)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index e59d3aae178..9b3b8ce6278 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -11,7 +11,6 @@ import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.UserPic -import nl.komponents.kovenant.Promise import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.ConfigurationSyncJob import org.session.libsession.messaging.jobs.JobQueue @@ -30,42 +29,63 @@ import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.Timer +import java.util.concurrent.ConcurrentLinkedDeque object ConfigurationMessageUtilities { private const val TAG = "ConfigMessageUtils" private val debouncer = WindowDebouncer(3000, Timer()) + private val destinationUpdater = Any() + private val pendingDestinations = ConcurrentLinkedDeque() - private fun scheduleConfigSync(userPublicKey: String) { - + private fun scheduleConfigSync(destination: Destination) { + synchronized(destinationUpdater) { + pendingDestinations.add(destination) + } debouncer.publish { // don't schedule job if we already have one val storage = MessagingModuleConfiguration.shared.storage - val ourDestination = Destination.Contact(userPublicKey) - val currentStorageJob = storage.getConfigSyncJob(ourDestination) - if (currentStorageJob != null) { - (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) - return@publish + val configFactory = MessagingModuleConfiguration.shared.configFactory + val destinations = synchronized(destinationUpdater) { + val objects = pendingDestinations.toList() + pendingDestinations.clear() + objects + } + destinations.forEach { destination -> + if (destination is Destination.ClosedGroup) { + // ensure we have the appropriate admin keys, skip this destination otherwise + val group = configFactory.userGroups?.getClosedGroup(destination.publicKey) ?: return@forEach + if (group.adminKey == null) return@forEach Log.w("ConfigurationSync", "Trying to schedule config sync for group we aren't an admin of") + } + val currentStorageJob = storage.getConfigSyncJob(destination) + if (currentStorageJob != null) { + (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) + return@publish + } + val newConfigSyncJob = ConfigurationSyncJob(destination) + JobQueue.shared.add(newConfigSyncJob) } - val newConfigSyncJob = ConfigurationSyncJob(ourDestination) - JobQueue.shared.add(newConfigSyncJob) } } @JvmStatic fun syncConfigurationIfNeeded(context: Context) { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Log.w(TAG, "User Public Key is null") - scheduleConfigSync(userPublicKey) + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return + scheduleConfigSync(Destination.Contact(userPublicKey)) } - fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) + fun forceSyncConfigurationNowIfNeeded(destination: Destination) { + scheduleConfigSync(destination) + } + + + fun forceSyncConfigurationNowIfNeeded(context: Context) { + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Log.e("Loki", NullPointerException("User Public Key is null")) // Schedule a new job if one doesn't already exist (only) - scheduleConfigSync(userPublicKey) - return Promise.ofSuccess(Unit) + scheduleConfigSync(Destination.Contact(userPublicKey)) } - private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes + private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()?.secretKey?.asBytes fun generateUserProfileConfigDump(): ByteArray? { val storage = MessagingModuleConfiguration.shared.storage @@ -151,7 +171,12 @@ object ConfigurationMessageUtilities { val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue convoConfig.getOrConstructCommunity(base, room, pubKey) } - recipient.isClosedGroupRecipient -> { + recipient.isClosedGroupV2Recipient -> { + // It's probably safe to assume there will never be a case where new closed groups will ever be there before a dump is created... + // but just in case... + convoConfig.getOrConstructClosedGroup(recipient.address.serialize()) + } + recipient.isLegacyClosedGroupRecipient -> { val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) convoConfig.getOrConstructLegacyGroup(groupPublicKey) } @@ -194,7 +219,7 @@ object ConfigurationMessageUtilities { } val allLgc = storage.getAllGroups(includeInactive = false).filter { - it.isClosedGroup && it.isActive && it.members.size > 1 + it.isLegacyClosedGroup && it.isActive && it.members.size > 1 }.mapNotNull { group -> val groupAddress = Address.fromSerialized(group.encodedId) val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() @@ -226,13 +251,13 @@ object ConfigurationMessageUtilities { @JvmField val DELETE_INACTIVE_GROUPS: String = """ - DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); - DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%'); """.trimIndent() @JvmField val DELETE_INACTIVE_ONE_TO_ONES: String = """ - DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_INBOX_PREFIX}%'; + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.LEGACY_CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_INBOX_PREFIX}%'; """.trimIndent() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt index 3984f38b516..99c6ff6b79d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -12,7 +12,9 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool && recipient.isOpenGroupInboxRecipient && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { return getOneToOne(recipient.address.serialize())?.unread == true - } else if (recipient.isClosedGroupRecipient) { + } else if (recipient.isClosedGroupV2Recipient) { + return getClosedGroup(recipient.address.serialize())?.unread == true + } else if (recipient.isLegacyClosedGroupRecipient) { return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true } else if (recipient.isCommunityRecipient) { val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false diff --git a/app/src/main/res/drawable/avatar_placeholder.xml b/app/src/main/res/drawable/avatar_placeholder.xml new file mode 100644 index 00000000000..0e0f2854177 --- /dev/null +++ b/app/src/main/res/drawable/avatar_placeholder.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/debug_border.xml b/app/src/main/res/drawable/debug_border.xml new file mode 100644 index 00000000000..e0a28b77e4c --- /dev/null +++ b/app/src/main/res/drawable/debug_border.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_admins.xml b/app/src/main/res/drawable/ic_add_admins.xml new file mode 100644 index 00000000000..b0b326ca3d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_admins.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_all_media.xml b/app/src/main/res/drawable/ic_all_media.xml new file mode 100644 index 00000000000..a9b3bdfdd1e --- /dev/null +++ b/app/src/main/res/drawable/ic_all_media.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_logout_24.xml b/app/src/main/res/drawable/ic_baseline_logout_24.xml new file mode 100644 index 00000000000..812160db4f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_logout_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear_messages.xml b/app/src/main/res/drawable/ic_clear_messages.xml new file mode 100644 index 00000000000..e79703910d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_messages.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_disappearing_messages.xml b/app/src/main/res/drawable/ic_disappearing_messages.xml new file mode 100644 index 00000000000..1e2de4e757c --- /dev/null +++ b/app/src/main/res/drawable/ic_disappearing_messages.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_edit_group.xml b/app/src/main/res/drawable/ic_edit_group.xml new file mode 100644 index 00000000000..f647fea3ea2 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_group.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_leave_group.xml b/app/src/main/res/drawable/ic_leave_group.xml new file mode 100644 index 00000000000..a6a235aeb7f --- /dev/null +++ b/app/src/main/res/drawable/ic_leave_group.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_link_out.xml b/app/src/main/res/drawable/ic_link_out.xml new file mode 100644 index 00000000000..40b1e94c6b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_link_out.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_media.xml b/app/src/main/res/drawable/ic_media.xml new file mode 100644 index 00000000000..16b35db22ae --- /dev/null +++ b/app/src/main/res/drawable/ic_media.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_notification_settings.xml b/app/src/main/res/drawable/ic_notification_settings.xml new file mode 100644 index 00000000000..e3dea6f2a2d --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pin_conversation.xml b/app/src/main/res/drawable/ic_pin_conversation.xml new file mode 100644 index 00000000000..b2ff304b359 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_conversation.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_question_mark.xml b/app/src/main/res/drawable/ic_question_mark.xml new file mode 100644 index 00000000000..d0c4088dcfe --- /dev/null +++ b/app/src/main/res/drawable/ic_question_mark.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_search_conversation.xml b/app/src/main/res/drawable/ic_search_conversation.xml new file mode 100644 index 00000000000..bd9eaad36cc --- /dev/null +++ b/app/src/main/res/drawable/ic_search_conversation.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/preference_single_no_padding.xml b/app/src/main/res/drawable/preference_single_no_padding.xml new file mode 100644 index 00000000000..483894fcc29 --- /dev/null +++ b/app/src/main/res/drawable/preference_single_no_padding.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/profile_picture_view_large_background.xml b/app/src/main/res/drawable/profile_picture_view_large_background.xml new file mode 100644 index 00000000000..9b90660803f --- /dev/null +++ b/app/src/main/res/drawable/profile_picture_view_large_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_notification_settings.xml b/app/src/main/res/layout/activity_conversation_notification_settings.xml new file mode 100644 index 00000000000..74ba7632e16 --- /dev/null +++ b/app/src/main/res/layout/activity_conversation_notification_settings.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_settings.xml b/app/src/main/res/layout/activity_conversation_settings.xml new file mode 100644 index 00000000000..d5f810d2cb5 --- /dev/null +++ b/app/src/main/res/layout/activity_conversation_settings.xml @@ -0,0 +1,375 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index d8e6b1fe73b..9c9acdd0506 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -230,7 +230,7 @@ app:layout_constraintTop_toBottomOf="@+id/toolbar" android:background="?danger" android:visibility="gone" - tools:visibility="visible"> + > + + + + + + + + + + android:text="d" /> diff --git a/app/src/main/res/layout/activity_edit_closed_group.xml b/app/src/main/res/layout/activity_edit_closed_group.xml index 006a493922e..2445fde11c3 100644 --- a/app/src/main/res/layout/activity_edit_closed_group.xml +++ b/app/src/main/res/layout/activity_edit_closed_group.xml @@ -4,7 +4,7 @@ android:layout_height="match_parent" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto" - tools:context="org.thoughtcrime.securesms.groups.EditClosedGroupActivity"> + tools:context="org.thoughtcrime.securesms.groups.EditLegacyGroupActivity"> + + diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 45b4ad733c0..a4882932a4b 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -42,7 +42,7 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_pending_attachment.xml b/app/src/main/res/layout/view_pending_attachment.xml new file mode 100644 index 00000000000..cb28cfac592 --- /dev/null +++ b/app/src/main/res/layout/view_pending_attachment.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_untrusted_attachment.xml b/app/src/main/res/layout/view_untrusted_attachment.xml deleted file mode 100644 index 0af509076d9..00000000000 --- a/app/src/main/res/layout/view_untrusted_attachment.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index 12197f816ac..fd7d02daf42 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -26,13 +26,13 @@ android:layout_height="wrap_content" /> - diff --git a/app/src/main/res/menu/menu_group_request.xml b/app/src/main/res/menu/menu_group_request.xml new file mode 100644 index 00000000000..367815d06f1 --- /dev/null +++ b/app/src/main/res/menu/menu_group_request.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 716d81601f7..9e325cbf629 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ + #FCB159 + #D8D8D8 #353535 #161616 #36383C diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 81cca8417ec..8a42dca4054 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -87,7 +87,7 @@ 40dp - 3 + 4 10dp @@ -126,5 +126,6 @@ 200dp 34dp 26dp + 26dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 045e125f3d8..5f382135754 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,3 +1,6 @@ + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 339139e8fc6..9b926cc1da1 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -18,6 +18,54 @@ @drawable/ic_arrow_left + + + + + + + + + + + +