From a0c0acb8fcd53b2c5e67ef709fd0187bb9a34094 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Thu, 19 Mar 2026 16:10:26 -0400 Subject: [PATCH] Add group terminate support. --- .../backupTests/account_data_00.binproto | Bin 296 -> 298 bytes .../backupTests/account_data_01.binproto | Bin 704 -> 673 bytes .../backupTests/account_data_02.binproto | Bin 674 -> 703 bytes .../backupTests/account_data_03.binproto | Bin 726 -> 725 bytes .../backupTests/account_data_04.binproto | Bin 599 -> 571 bytes .../backupTests/account_data_05.binproto | Bin 742 -> 768 bytes .../backupTests/account_data_06.binproto | Bin 588 -> 590 bytes .../backupTests/account_data_07.binproto | Bin 584 -> 553 bytes .../backupTests/account_data_08.binproto | Bin 483 -> 512 bytes .../backupTests/account_data_09.binproto | Bin 481 -> 480 bytes .../backupTests/account_data_10.binproto | Bin 503 -> 475 bytes .../backupTests/account_data_11.binproto | Bin 591 -> 617 bytes .../backupTests/account_data_12.binproto | Bin 377 -> 379 bytes .../backupTests/account_data_13.binproto | Bin 605 -> 574 bytes .../backupTests/account_data_14.binproto | Bin 519 -> 548 bytes .../backupTests/account_data_15.binproto | Bin 548 -> 547 bytes .../backupTests/account_data_16.binproto | Bin 447 -> 419 bytes .../backupTests/account_data_17.binproto | Bin 593 -> 619 bytes .../backupTests/account_data_18.binproto | Bin 386 -> 388 bytes .../backupTests/account_data_19.binproto | Bin 582 -> 551 bytes .../backupTests/account_data_20.binproto | Bin 460 -> 489 bytes .../backupTests/account_data_21.binproto | Bin 551 -> 550 bytes .../backupTests/account_data_22.binproto | Bin 506 -> 478 bytes .../backupTests/account_data_23.binproto | Bin 612 -> 638 bytes .../backupTests/account_data_24.binproto | Bin 375 -> 377 bytes .../backupTests/account_data_25.binproto | Bin 582 -> 551 bytes .../backupTests/account_data_26.binproto | Bin 520 -> 549 bytes .../backupTests/account_data_27.binproto | Bin 482 -> 481 bytes ...up_change_chat_multiple_update_16.binproto | Bin 1209 -> 1232 bytes ...up_change_chat_multiple_update_17.binproto | Bin 0 -> 1178 bytes ..._item_group_change_chat_update_79.binproto | Bin 0 -> 1184 bytes ..._item_group_change_chat_update_80.binproto | Bin 0 -> 1184 bytes ..._item_group_change_chat_update_81.binproto | Bin 0 -> 1184 bytes ..._item_group_change_chat_update_82.binproto | Bin 0 -> 1182 bytes ..._item_group_change_chat_update_83.binproto | Bin 0 -> 1182 bytes ..._item_group_change_chat_update_84.binproto | Bin 0 -> 1182 bytes .../backupTests/recipient_groups_00.binproto | Bin 1364 -> 1366 bytes .../backupTests/recipient_groups_01.binproto | Bin 1606 -> 1606 bytes .../backupTests/recipient_groups_02.binproto | Bin 1569 -> 1571 bytes .../backupTests/recipient_groups_03.binproto | Bin 1501 -> 1501 bytes .../backupTests/recipient_groups_04.binproto | Bin 1477 -> 1479 bytes .../backupTests/recipient_groups_05.binproto | Bin 1366 -> 1368 bytes .../backupTests/recipient_groups_06.binproto | Bin 1602 -> 1602 bytes .../backupTests/recipient_groups_07.binproto | Bin 1573 -> 1575 bytes .../backupTests/recipient_groups_08.binproto | Bin 1499 -> 1499 bytes .../backupTests/recipient_groups_09.binproto | Bin 1477 -> 1479 bytes .../backupTests/recipient_groups_10.binproto | Bin 1366 -> 1368 bytes .../backupTests/recipient_groups_11.binproto | Bin 1604 -> 1604 bytes ...est_collapseJoinRequestEventsIfPossible.kt | 2 +- .../securesms/BlockUnblockDialog.java | 4 +- .../RecipientTableArchiveExtensions.kt | 2 +- .../v2/exporters/GroupArchiveExporter.kt | 11 +- .../v2/importer/GroupArchiveImporter.kt | 1 + .../blocked/BlockedUsersActivity.java | 2 +- .../securesms/calls/log/CallLogContextMenu.kt | 6 +- .../ConversationSettingsFragment.kt | 123 ++++++++++++++- .../ConversationSettingsRepository.kt | 17 +- .../conversation/ConversationSettingsState.kt | 6 + .../ConversationSettingsViewModel.kt | 37 ++++- .../PermissionsSettingsViewModel.kt | 5 +- .../preferences/AvatarPreference.kt | 10 +- .../preferences/TerminatedBannerPreference.kt | 29 ++++ .../paged/ContactSearchPagedDataSource.kt | 2 +- .../conversation/ConversationOptionsMenu.kt | 11 +- .../securesms/conversation/MenuState.java | 4 +- .../conversation/v2/ConversationAdapterV2.kt | 8 +- .../conversation/v2/ConversationDialogs.kt | 13 ++ .../conversation/v2/ConversationFragment.kt | 26 +++- .../conversation/v2/ConversationViewModel.kt | 2 +- .../conversation/v2/DisabledInputView.kt | 9 ++ .../conversation/v2/InputReadyState.kt | 3 +- .../securesms/conversation/v2/PinSendUtil.kt | 10 ++ .../v2/TerminatedGroupBottomSheetDialog.kt | 88 +++++++++++ .../securesms/database/GroupTable.kt | 88 +++++++---- .../securesms/database/MessageTable.kt | 2 +- .../securesms/database/RecipientTable.kt | 4 +- .../securesms/database/ThreadTable.kt | 8 +- .../helpers/SignalDatabaseMigrations.kt | 6 +- .../V309_GroupTerminatedColumnMigration.kt | 15 ++ .../securesms/database/model/GroupRecord.kt | 12 +- .../model/GroupsV2UpdateMessageConverter.kt | 16 ++ .../model/GroupsV2UpdateMessageProducer.java | 15 ++ .../securesms/groups/GroupManager.java | 19 ++- .../securesms/groups/GroupManagerV2.java | 7 + .../securesms/groups/LiveGroup.java | 18 ++- .../memberlabel/MemberLabelRepository.kt | 2 + .../securesms/groups/ui/EndGroupDialog.kt | 92 +++++++++++ .../v2/processing/GroupsV2StateProcessor.kt | 48 ++++-- .../jobs/ForceUpdateGroupV2WorkerJob.java | 5 + .../jobs/RequestGroupV2InfoWorkerJob.java | 5 + .../mediasend/CameraContactsRepository.java | 2 +- .../securesms/messagerequests/GroupInfo.kt | 4 +- .../MessageRequestRepository.java | 4 +- .../messages/MessageContentProcessor.kt | 7 + .../securesms/mms/IncomingMessage.kt | 6 +- .../profiles/edit/CreateProfileFragment.java | 1 + .../edit/EditGroupProfileRepository.java | 1 + .../securesms/recipients/Recipient.kt | 2 +- .../bottomsheet/RecipientDialogViewModel.java | 27 ++-- .../ShareableGroupLinkViewModel.java | 3 +- .../securesms/util/CommunicationActions.java | 4 +- .../securesms/util/RemoteConfig.kt | 12 ++ app/src/main/protowire/Backup.proto | 6 + .../drawable/terminated_banner_background.xml | 8 + .../layout/conversation_group_terminated.xml | 14 ++ ...onversation_settings_terminated_banner.xml | 24 +++ app/src/main/res/values/strings.xml | 46 ++++++ .../conversation/GroupSettingsStateTest.kt | 51 ++++++ .../PermissionsSettingsViewModelTest.kt | 1 + .../securesms/database/GroupTestUtil.kt | 21 ++- .../groups/GroupManagerV2Test_edit.kt | 26 ++++ .../processing/GroupsV2StateProcessorTest.kt | 147 ++++++++++++++++++ .../api/groupsv2/ChangeSetModifier.java | 2 + ...upChangeActionsBuilderChangeSetModifier.kt | 4 + .../api/groupsv2/DecryptedGroupExtensions.kt | 2 + .../api/groupsv2/DecryptedGroupUtil.java | 8 + ...upChangeActionsBuilderChangeSetModifier.kt | 4 + .../api/groupsv2/GroupChangeReconstruct.java | 4 + .../api/groupsv2/GroupChangeUtil.java | 13 +- .../api/groupsv2/GroupsV2Operations.java | 14 +- .../src/main/protowire/DecryptedGroups.proto | 2 + .../src/main/protowire/Groups.proto | 8 +- .../DecryptedGroupUtil_apply_Test.java | 21 ++- .../DecryptedGroupUtil_empty_Test.java | 12 +- .../groupsv2/GroupChangeReconstructTest.java | 20 ++- .../GroupChangeUtil_changeIsEmpty_Test.java | 11 +- .../GroupChangeUtil_resolveConflict_Test.java | 55 ++++++- ...il_resolveConflict_decryptedOnly_Test.java | 43 ++++- ...roupsV2Operations_decrypt_change_Test.java | 12 +- ...GroupsV2Operations_decrypt_group_Test.java | 13 +- 130 files changed, 1312 insertions(+), 146 deletions(-) create mode 100644 app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_17.binproto create mode 100644 app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_79.binproto create mode 100644 app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_80.binproto create mode 100644 app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_81.binproto create mode 100644 app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_82.binproto create mode 100644 app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_83.binproto create mode 100644 app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_84.binproto create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/TerminatedBannerPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_GroupTerminatedColumnMigration.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/EndGroupDialog.kt create mode 100644 app/src/main/res/drawable/terminated_banner_background.xml create mode 100644 app/src/main/res/layout/conversation_group_terminated.xml create mode 100644 app/src/main/res/layout/conversation_settings_terminated_banner.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt diff --git a/app/src/androidTest/assets/backupTests/account_data_00.binproto b/app/src/androidTest/assets/backupTests/account_data_00.binproto index 9f3ca718a3be3e933be8aace5135e1b4339a3d51..3186ce974734e2ad63f0f2f07cea78058e2bdadd 100644 GIT binary patch delta 44 zcmZ3%w2EnhAIn-su2mC*x}>Bs)ARC65*0E_Q!!8ic*VGCw?&j0AY&| A%m4rY delta 42 ycmZ3*w1R1ZAIoY+t`!r5x+EkrOH(qF6O%Ga6!Mcxi;7ZGXVfX8xQgT diff --git a/app/src/androidTest/assets/backupTests/account_data_01.binproto b/app/src/androidTest/assets/backupTests/account_data_01.binproto index 4c59bbfb415755c1f47ffe58435737849adde39d..3f1b5a506631b3761a980d7b10c80f55bfec67e2 100644 GIT binary patch delta 63 zcmX@Wx{!5(AIlOJu7wkW4l}-=EXMdjEsD+1$k@cxEK4RgQ=vFBFTFG;vsfW5F()Te TA+;#KSfL=ls3?E3H&ZD9y2TbA delta 94 zcmZ3;dVqC;AIo7Dt^*T;4l{n5EXMf3wSkE-ip|i-*u>N<%c3|lFTFG;vsfW5F()Te sA+;#KSfL=ls3>0{C9xngskB%jH?;&v7Nq7SmVo&sKsrBfvJO)z05G2+_y7O^ diff --git a/app/src/androidTest/assets/backupTests/account_data_02.binproto b/app/src/androidTest/assets/backupTests/account_data_02.binproto index d33e90f5795301ff8fc3214335a6567b6585d5ef..1261027d243dbb51308fc111f7f267d7d639524a 100644 GIT binary patch delta 50 zcmZ3)x}SA|AIl*YuKgQ>n3?3va#Kr6ixmn|^AbxklYp!eAf2D5P?lMgS*$QQjL8Q8 Dz84Xt delta 21 dcmdnbx`=gxAInk}u0VKOz)n delta 55 zcmcc0dX060AInV^u4@y67BEiO_?eH9sex${lcR)0W@$=ha$-_ui9&vIX;D#XQEG`o LYEI_lXr?LvIQbM3 diff --git a/app/src/androidTest/assets/backupTests/account_data_04.binproto b/app/src/androidTest/assets/backupTests/account_data_04.binproto index 1c105ab3d0f1da863ece65b1246bd66001a0e3d4..0d673310e0eb74441d82a54d2ce6f53c36221beb 100644 GIT binary patch delta 49 zcmcc4vYTasAIm;wuH74hCNav&MPbkQ8c diff --git a/app/src/androidTest/assets/backupTests/account_data_05.binproto b/app/src/androidTest/assets/backupTests/account_data_05.binproto index d1b15898f0caa6b621e57051bc107c69910809c5..8fbce2f249168e09eaf696af77907b1e9985fcfd 100644 GIT binary patch delta 61 zcmaFH+Q2r!kF}YVtATZ5@NUNb&HRi4jFXj^Oy$gSQ%g#V6$(=G5=%0ZfUFWAou8*r OmRXcptS~u(sSf~H+!S8` delta 35 rcmZo*d&WA!kM$)B*E5!h!MhnJZRTebU}S1wnk>g;#waH!mSQpi0CV#X A^#A|> diff --git a/app/src/androidTest/assets/backupTests/account_data_07.binproto b/app/src/androidTest/assets/backupTests/account_data_07.binproto index d0f2e351fea1dff54d734279ac6d50807403ebc8..543723762e043f571f60c7a2090910a70b2262f8 100644 GIT binary patch delta 43 zcmX@XvXW(jAIlnMu9Xvm4l!0t7Gvyt delta 75 zcmZ3PIgnA9MIkjOb8<3cEdVff4JZHr diff --git a/app/src/androidTest/assets/backupTests/account_data_10.binproto b/app/src/androidTest/assets/backupTests/account_data_10.binproto index 990e8a1656593b73f988f4b78527320c0a423b81..a1ba413e9810213ac22163afc276c20f9eafb49c 100644 GIT binary patch delta 28 kcmey)e4BZKAIm)^uG<@fEE!p3ax)bs_b^IMKEh}W0G7oGmjD0& delta 57 zcmcc3{GEA%AImQ$uJ0RzEEyRsCigQ+$}6NK7Gx%s7AxeYmH^3u)V#zJFuw#y=jTmc H!DtKsT6+}e diff --git a/app/src/androidTest/assets/backupTests/account_data_11.binproto b/app/src/androidTest/assets/backupTests/account_data_11.binproto index 1a89d507fb6c97da9742d834d5757f2411d9d744..3204db3c3a2eb2f71fc6158eb55be4e2c1dab559 100644 GIT binary patch delta 59 zcmX@l@{(nOAIlqNu9p*ob~9E?<^$qRMsqo{+|-iNVuga#yu^~sBp|BRuHO@bZc9mJrsw6CBr0Te!^G@0Q|iS@&Et; delta 69 zcmdnTa+hU-AIk$~uDcV14l`Cy7GtbnYG9h&%P7rgF?kK6q`X2(VnJq7X|Y0XY6*}m TNX<(u0rN|Mbbj9Cql}dRNc(fd5I;NNkCQ!kj~FjD9bF$ELNDjg)tZa Dxd##V delta 21 ccmZ3&(#|r$kEM&5t9@foA|s>Z)VAIoxPt|b$L7BH4g{9Me`z%)6PQBOi5vos|$IWZ}-L?J)9w5TYxD78c( LH79fOTE;Q}BM}r3 diff --git a/app/src/androidTest/assets/backupTests/account_data_16.binproto b/app/src/androidTest/assets/backupTests/account_data_16.binproto index 893acc65abb9c0ca631b0e4af3a31c3ff4883a78..9dd3dbef3af0ae387849e84822e8720f383ed297 100644 GIT binary patch delta 49 zcmdnbyqI}{AImZ(uEiUJ7#U?{ax)c*GxO3*b25t+(h_rWG8Iyb@{1J;@{5Y{C-*QK F0|1xE5a<8^ delta 77 zcmZ3?yq|f3AIl*ouKgQ>7#S@siZk=lOLH=d719!OaxxWCi}H&V3i6AJ@)c4N3o?^R bixqNHOMqlSYF=Uqm|p^<^YbPbFd72@HR2pL diff --git a/app/src/androidTest/assets/backupTests/account_data_17.binproto b/app/src/androidTest/assets/backupTests/account_data_17.binproto index 7e89e1addb73731483f130293ad89c12616dcb8a..190a40715db743c41e9e2164a316e1c1ad671e7a 100644 GIT binary patch delta 59 zcmcb}@|tCWAIm#tuGbTT_ApjV<_F>~MhiK!+|-iNVuga#yu^~sBp|B delta 33 pcmaFOa*<_%AIlYHu8R|c_ApjY=4Y&6YG9h&#%ReXIr%PQ9{|st3x5Cr diff --git a/app/src/androidTest/assets/backupTests/account_data_18.binproto b/app/src/androidTest/assets/backupTests/account_data_18.binproto index c62554ed61404dca45eb43c0ff5292b7419128c4..d1ec48ed14b4482a1161fad28f7c2360cd7d7e05 100644 GIT binary patch delta 45 zcmZo-ZegC_$I{Nk)iN>Yfs|BcdR~4>qC#eAN@j9mQf7%lesXD1QEE}@WG_Yw0Aq*` A>Hq)$ delta 43 ycmZo+ZepI`$I{Bg)ig2afrLb6X-Z~tVp3*_LVj{-QBi79YKcN>PUd7EMhgH#yblln diff --git a/app/src/androidTest/assets/backupTests/account_data_19.binproto b/app/src/androidTest/assets/backupTests/account_data_19.binproto index 0818c9393101eeb91c867a9b2a43c40b9759872c..2320be5aefefb1ebcc12e821a31ad12ff8b5b85b 100644 GIT binary patch delta 55 zcmX@cvYcgtAImCcuH_Sh4l!0t7GR4C5OOE1mIELKQM%*n}ANG-}QRw&3X LD$1XHim?U&9^e!z delta 86 zcmZ3^a*SnyAInK*u45B}4l!0w7G^~AIo_rt}`2hbQu{XC(mW{0RUD@2MquK diff --git a/app/src/androidTest/assets/backupTests/account_data_21.binproto b/app/src/androidTest/assets/backupTests/account_data_21.binproto index b3593e952143ce8fe3282c2b316fa95c4d1ba1df..8dd00d3677c99b9acceeb572d444e315773f3435 100644 GIT binary patch delta 54 zcmZ3^vW#VdAInN+u4NO07BUu3{8BtQi&0-nDlhwdGi diff --git a/app/src/androidTest/assets/backupTests/account_data_23.binproto b/app/src/androidTest/assets/backupTests/account_data_23.binproto index 1736c477c0cdfd1a721da0ae6ed36beb37eea43e..04c82c11a87791f05bdff0c32aaa0e13884dea60 100644 GIT binary patch delta 59 zcmaFD@{eVLA4?+(*T0EDyBRAc^8s-uqlcVXZfZ$su|h#=USdgR5|C8_r1SF>$})>G LixnncVw?m3nfexS delta 33 pcmeyz@`PoAAIo!Qt|t?Nb~9E_=3}g2YG9h&%IL``Ir$CaBmmfS3+n&? diff --git a/app/src/androidTest/assets/backupTests/account_data_24.binproto b/app/src/androidTest/assets/backupTests/account_data_24.binproto index 73e07d057a218804dee37bf31ffe27d5cc0b8992..1e18c74a71161c1d88bfe51a753d3151b9d95f83 100644 GIT binary patch delta 45 zcmey)^pk0VAIl#`uAdWwE=fscrsw6CBr0T(~)0g?r&d5I-pehHAy&zro7u@nHqn;|p+ diff --git a/app/src/androidTest/assets/backupTests/account_data_26.binproto b/app/src/androidTest/assets/backupTests/account_data_26.binproto index d34b2eece85a57816d1344f757ce69e7f0e4c36f..8d8c28b33eb533d2df0e88fabe6d2569dbc27ccc 100644 GIT binary patch delta 50 zcmeBRS;{iOk7Wfj*V2tanT&E~xv3?k#R>(fd5I;NNkCQ!kj~FjD9bF$ELNDjhtUTB Dy7dwS delta 21 ccmZ3=(!nypkENTLt7BtOCL^Qd07uRScmMzZ diff --git a/app/src/androidTest/assets/backupTests/account_data_27.binproto b/app/src/androidTest/assets/backupTests/account_data_27.binproto index 699a64a2acc9c9d78a65a87887284d6668619e1c..8208d46c484d2c6ccebda533eb7059f031491ab7 100644 GIT binary patch delta 54 zcmaFF{E&HqAIlRau7?wYq8W=Pt}C9b!e}5Rm6@KGUy`VhS(=iWoS2kZqL80lT2z!; KlsY+wu?zq)xfDtO delta 55 zcmaFJ{D^siAInoFu16Dtq8ZC3t}AA0V4AGJXec3(S(=iWoS2kZqL80lT2z!;lv<*Y Lnv*#>o3RW4G>Q}V diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_16.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_16.binproto index 68a5e5046a798779167b0b302fc26671059b576e..55b9b206f3959fa7784ba16ae00689fd0165209b 100644 GIT binary patch delta 60 zcmdnVd4Y37F^foyQWOWX0Heg-OON;Tmxs!84F^h<+k~0Uh0Heg-OON;TmPg&uX@7OF$|<3Qi=|L@kKg3}r(jHvW3WH`}f4YoJIa z#2^WZWAVp^KW3t`>0%ApbY{zpBpNYB(SXK8t-2AR`-2hpBPNciG4(+;+Qx8m{y6u0 zPfkAfp6@Lsz(=079UaOu(%t9g^|gK;I(}{3SJ}UxF8ekM>&;{Cta9{ae4Ke?{ev^6 z@g=HS&~ zTrp#LkMm(~xU@%^EHEp}i$!p3)UFI7P?!!@DBLsE4ze9wcBoteWl#YdzzqQiK?6WD z;6tD%U_c1PhBK^Xv5|~1pcsms1?U7q7@iR^#SuUc0R{m62OvTU0ShLnDT0pRd^A-s z62_Fy@mi~dWyB)$1qeHU=!8bl&bjw`5L$p5;#D9dE12%<|1CDxLGP}u88w{jLa(P% zak1q-PtaX8jUB9MYG3Z_+iUxeJ?i;|yX9SZDr-x0N^eE))pP0V(wBt+Y$(pc##vBm zY7$GLg6aM^i;iD7YB(8fRxZ||`ju*pWM;3|cjMIf7uPTM4|b(F#ZHfBPkpQOL$ocr zsI{ekYY4)CZD+LoKEgk)d9t4Niq zFIV0y2jwd1v%n?`A(+Raj$$j=CcZZA8)Nc#L`||Q|Ef08I6M^Ui7`d{C|nt2nliKm g#53Cu_C1~b392&)6FBBEAdY(f|Me literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_79.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_79.binproto new file mode 100644 index 0000000000000000000000000000000000000000..a087ce288d1eb28a5948283d50f24e08349a2f85 GIT binary patch literal 1184 zcmea}U=+CVYr*{QhEfXsyKEj#$*x#(ig%Lc-3C}+#osG&J zqAx{nsQlHNEF_S4kZ}>iRVVSzL#z@vJgYuPuqd%O0Zq|jaRVA-rOBo6W_QMM6Jsg8 z#l~lsyPxcF4Ng@`jL`N!7xm!Y3g2_7l0wX(!7dVvUMWB?GfFTjFlsOwFj`EIabiq# zVoY*kO!i<5V2of)V9a1FV60$lU}WrIWSYgu&cP_kz`*by#9{)n{)1V}q745T{_kL9 zJjBSs!7RWm!KA=+gOT9@BjXE3#vhD~971dyj9N?_+-yQD985+GGD1=u%t~Th0zjis zfE1Gia~GpfJ0k~+P!l6io=fl?1A_xY14I?05V{DH!qi81{#$z~T#qfqi=tQ^b25`t zlURduGfOgx6j@)DwyyTieK^tT8|(bctz4IKLf5NTK9;^0aqq+~u{bfm6^x8l3=p@l zLfpc@&5h<3R1qeHX%M%t2sq+JQOv-&N@DfO&r2-^x3dR+TOjZnij6&Uv9Be{upm1S0#fa_}6cHwc=@7SYC^X?k zQLN61Mfo{N>|Xg9dByp8K=-(rXKr6zWwB3Z|MC~-zdx?oZB))%rd)6|=JyL_w@CYt z3Z@l|%vKB?j6$u99PC03VD~WGLU#{}2$RALhjq2MZ8Mzctk`oq= z0Ham|Fd1?2`KIb9fRlud0x&kDms*R{*1qk`|D`C^7G1 Y)6bj6K@3&WLXuqKvzS13pqs`70RMeC@&Et; literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_80.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_80.binproto new file mode 100644 index 0000000000000000000000000000000000000000..4a3e9d2957b2ebe9c66b8735fff85a620b4484cc GIT binary patch literal 1184 zcmea}U=+CVYr*{QhEfXsyKEj#$*x#(ig%Lc-3C}+#osG&J zqAx{nsQlHNEF_S4kZ}>iRVVSzL#z@vJgYuPuqd%O0Zq|jaRVA-rOBo6W_QMM6Jsg8 z#l~lsyPxcF4Ng@`jL`N!7xm!Y3g2_7l0wX(!7dVvUMWB?GfFTjFlsOwFj`EIabiq# zVoY*kO!i<5V2of)V9a1FV60$lU}WrIWSYgu&cP_kz`*by#9{)n{)1V}q745T{_kL9 zJjBSs!7RWm!KA=+gOT9@BjXE3#vhD~971dyj9N?_+-yQD985+GGD1=u%t~Th0zjis zfE1Gia~GpfJ0k~+P!l6io=fl?1A_xY14I?05V{DH!qi81{#$z~T#qfqi=tQ^b25`t zlURduGfOgx6j@)DwyyTieK^tT8|(bctz4IKLf5NTK9;^0aqq+~u{bfm6^x8l3=p@l zLfpc@&5h<3R1qeHX%M%t2sq+JQOv-&N@DfO&r2-^x3dR+TOjZnij6&Uv9Be{upm1S0#fa_}6cHwc=@7SYC^X?k zQLN61Mfo{N>|Xg9dByp8K=-(rXKr6zWwB3Z|MC~-zdx?oZB))%rd)6|=JyL_w@CYt z3Z@l|%vKB?j6$u99PC03VD~WGLU#{}2$RALhjq2MZ8Mzctk`oq= z0Ham|Fd1?2`KIb9fRlud0x&kDms*R{*1qk`|D`C^37= Y-3K>Jf*7i#g(SJeXEA~8K-0$z0P`X_A^-pY literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_81.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_81.binproto new file mode 100644 index 0000000000000000000000000000000000000000..21dc7f1e68fc5811d8ec634e2da1d10b4748b2ba GIT binary patch literal 1184 zcmea}U=+CVYr*{QhEfXsyKEj#$*x#(ig%Lc-3C}+#osG&J zqAx{nsQlHNEF_S4kZ}>iRVVSzL#z@vJgYuPuqd%O0Zq|jaRVA-rOBo6W_QMM6Jsg8 z#l~lsyPxcF4Ng@`jL`N!7xm!Y3g2_7l0wX(!7dVvUMWB?GfFTjFlsOwFj`EIabiq# zVoY*kO!i<5V2of)V9a1FV60$lU}WrIWSYgu&cP_kz`*by#9{)n{)1V}q745T{_kL9 zJjBSs!7RWm!KA=+gOT9@BjXE3#vhD~971dyj9N?_+-yQD985+GGD1=u%t~Th0zjis zfE1Gia~GpfJ0k~+P!l6io=fl?1A_xY14I?05V{DH!qi81{#$z~T#qfqi=tQ^b25`t zlURduGfOgx6j@)DwyyTieK^tT8|(bctz4IKLf5NTK9;^0aqq+~u{bfm6^x8l3=p@l zLfpc@&5h<3R1qeHX%M%t2sq+JQOv-&N@DfO&r2-^x3dR+TOjZnij6&Uv9Be{upm1S0#fa_}6cHwc=@7SYC^X?k zQLN61Mfo{N>|Xg9dByp8K=-(rXKr6zWwB3Z|MC~-zdx?oZB))%rd)6|=JyL_w@CYt z3Z@l|%vKB?j6$u99PC03VD~WGLU#{}2$RALhjq2MZ8Mzctk`oq= z0Ham|Fd1?2`KIb9fRlud0x&kDms*R{*1qk`|D`C^7G1 V)6bj6K@3&WLXuqKvzV~j0RZu_ISBv& literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_82.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_82.binproto new file mode 100644 index 0000000000000000000000000000000000000000..61b080f35d482a1183a12f682f92df54add71fe0 GIT binary patch literal 1182 zcmea}U=+CVYr*{QhEfXsyKEj#$*x#(ig%Lc-3C}+#osG&J zqAx{nsQlHNEF_S4kZ}>iRVVSzL#z@vJgYuPuqd%O0Zq|jaRVA-rOBo6W_QMM6Jsg8 z#l~lsyPxcF4Ng@`jL`N!7xm!Y3g2_7l0wX(!7dVvUMWB?GfFTjFlsOwFj`EIabiq# zVoY*kO!i<5V2of)V9a1FV60$lU}WrIWSYgu&cP_kz`*by#9{)n{)1V}q745T{_kL9 zJjBSs!7RWm!KA=+gOT9@BjXE3#vhD~971dyj9N?_+-yQD985+GGD1=u%t~Th0zjis zfE1Gia~GpfJ0k~+P!l6io=fl?1A_xY14I?05V{DH!qi81{#$z~T#qfqi=tQ^b25`t zlURduGfOgx6j@)DwyyTieK^tT8|(bctz4IKLf5NTK9;^0aqq+~u{bfm6^x8l3=p@l zLfpc@&5h<3R1qeHX%M%t2sq+JQOv-&N@DfO&r2-^x3dR+TOjZnij6&Uv9Be{upm1S0#fa_}6cHwc=@7SYC^X?k zQLN61Mfo{N>|Xg9dByp8K=-(rXKr6zWwB3Z|MC~-zdx?oZB))%rd)6|=JyL_w@CYt z3Z@l|%vKB?j6$u99PC03VD~WGLU#{}2$RALhjq2MZ8Mzctk`oq= z0Ham|Fd1?2`KIb9fRlud0x&kDms*R{*1yk_M2$C^37= W-3K>Jf*7hKg~Yi;7cmK8CVl{l{5jqL literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_83.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_83.binproto new file mode 100644 index 0000000000000000000000000000000000000000..eca84dc9476768d0e1141f0dc4e71103b2579dd7 GIT binary patch literal 1182 zcmea}U=+CVYr*{QhEfXsyKEj#$*x#(ig%Lc-3C}+#osG&J zqAx{nsQlHNEF_S4kZ}>iRVVSzL#z@vJgYuPuqd%O0Zq|jaRVA-rOBo6W_QMM6Jsg8 z#l~lsyPxcF4Ng@`jL`N!7xm!Y3g2_7l0wX(!7dVvUMWB?GfFTjFlsOwFj`EIabiq# zVoY*kO!i<5V2of)V9a1FV60$lU}WrIWSYgu&cP_kz`*by#9{)n{)1V}q745T{_kL9 zJjBSs!7RWm!KA=+gOT9@BjXE3#vhD~971dyj9N?_+-yQD985+GGD1=u%t~Th0zjis zfE1Gia~GpfJ0k~+P!l6io=fl?1A_xY14I?05V{DH!qi81{#$z~T#qfqi=tQ^b25`t zlURduGfOgx6j@)DwyyTieK^tT8|(bctz4IKLf5NTK9;^0aqq+~u{bfm6^x8l3=p@l zLfpc@&5h<3R1qeHX%M%t2sq+JQOv-&N@DfO&r2-^x3dR+TOjZnij6&Uv9Be{upm1S0#fa_}6cHwc=@7SYC^X?k zQLN61Mfo{N>|Xg9dByp8K=-(rXKr6zWwB3Z|MC~-zdx?oZB))%rd)6|=JyL_w@CYt z3Z@l|%vKB?j6$u99PC03VD~WGLU#{}2$RALhjq2MZ8Mzctk`oq= z0Ham|Fd1?2`KIb9fRlud0x&kDms*R{*1yk_M2$C^7G1 Y)6bj6K@3%rLgHMai<{9 literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_84.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_84.binproto new file mode 100644 index 0000000000000000000000000000000000000000..4bbe26616c6dd643386cc6b70c901a823f218159 GIT binary patch literal 1182 zcmea}U=+CVYr*{QhEfXsyKEj#$*x#(ig%Lc-3C}+#osG&J zqAx{nsQlHNEF_S4kZ}>iRVVSzL#z@vJgYuPuqd%O0Zq|jaRVA-rOBo6W_QMM6Jsg8 z#l~lsyPxcF4Ng@`jL`N!7xm!Y3g2_7l0wX(!7dVvUMWB?GfFTjFlsOwFj`EIabiq# zVoY*kO!i<5V2of)V9a1FV60$lU}WrIWSYgu&cP_kz`*by#9{)n{)1V}q745T{_kL9 zJjBSs!7RWm!KA=+gOT9@BjXE3#vhD~971dyj9N?_+-yQD985+GGD1=u%t~Th0zjis zfE1Gia~GpfJ0k~+P!l6io=fl?1A_xY14I?05V{DH!qi81{#$z~T#qfqi=tQ^b25`t zlURduGfOgx6j@)DwyyTieK^tT8|(bctz4IKLf5NTK9;^0aqq+~u{bfm6^x8l3=p@l zLfpc@&5h<3R1qeHX%M%t2sq+JQOv-&N@DfO&r2-^x3dR+TOjZnij6&Uv9Be{upm1S0#fa_}6cHwc=@7SYC^X?k zQLN61Mfo{N>|Xg9dByp8K=-(rXKr6zWwB3Z|MC~-zdx?oZB))%rd)6|=JyL_w@CYt z3Z@l|%vKB?j6$u99PC03VD~WGLU#{}2$RALhjq2MZ8Mzctk`oq= z0Ham|Fd1?2`KIb9fRlud0x&kDms*R{*1yk_M2$C^37= W-3K>Jf*7hKg~Yi;7cmK8+5rH60yy;m literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto index 132937538a4153f2ae790c7c4ea8fb8b576a2f75..5992a98f7574eb3c66d734a22e7d88ff3b5c1e9e 100644 GIT binary patch delta 74 zcmV-Q0JZ*930? D&*2pM diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto index d5a76db9f72c953750cb356e67dde0fee5ed1941..34d9bf4c18e4b5a23c6a013a7db2079283e7f1d5 100644 GIT binary patch delta 82 zcmV-Y0ImO_45JLNs{}CS0uti_2nrhB0tz4)TaZ}q!TLyUHY$t$g8IY`o@W94a{K3Q owHm;UhaR^e0xGq$+XOTL6mS7J0uLGu2mufZDA?V?l&1wy0ZcC+KL7v# delta 80 zcmV-W0I&a}4519Ls{}CQ0utc@2nrh90tz5*wHm;UhaR_YE`B&A)m?T_53Obh;F9FN m%lp0_DC3JD0xGk!+XOTL5;y`68Vv{m5DF;R-NKZo1yBLQJ{@8J diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto index 3f13df046bb32699f762e5b826758a6601b500f2..f60d6a6b146ffbb69652c35a17da99ff9b293bd9 100644 GIT binary patch delta 45 zcmV+|0Mh^63*8H_w*(**8~Ou D)}a)= diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto index 9b1b05dea4227f8628efa06bdba52beb9ee55398..328309681b6845cc1ab5a90b28a79301aa9c1d0a 100644 GIT binary patch delta 110 zcmV-!0FnR23&#tvs{}HU0uqh_2nrgB0tz4)TaZ}q!TLyUHY$t$g8IY`o@W94a{K3Q zwHm;UhaR^O0U!Y?-jm$~yD!=QXv>To#&HRH*|flI%Ek*TU_k|yDdLJ9!lSn(&CmB;*-PYZ%!CnkXY}*`bcgzDvSPt`ov%X OI0Oh91_%KV3Qz$nL@QJP diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto index fbb961679e7d3036a477a05e23c2b913fc2ec933..400f3a99c1ec761cb1214b9c37491c310d8b2da2 100644 GIT binary patch delta 76 zcmV-S0JHzr3fKy;s{}Hl0TQ192nrgS0SX`(TaZ}q!TLyUHY$t$g8IY`o@W94a{K3Q iwHm;UhaR^W0U!b@aFg8xZVhk&I0XnA1_%KV3Qz%Z{2JB( delta 74 zcmV-Q0JZvZVNaC2pR?m0T2pM0rsvK`2YX_ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto index ac1b5ec1c353dc587b96205d9aab389af197ed57..382362896ed28f9beb804333f9958eda2b92d7f9 100644 GIT binary patch delta 45 zcmV+|0Mh@$48jbsw*(**92<; D&UqB> diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto index 5f80a6575603cb23ad88fccec3279972176a0f39..1f3d18cf514c305e2844e3bb326eeb229c00b607 100644 GIT binary patch delta 86 zcmV-c0IC0_45tjRs{}Oa0utu}2nrhF0tz4)TaZ}q!TLyUHY$t$g8IY`o@W94a{K3Q swHm;UhaR^O0T=-w0V=h#-vl%P6mS7J2M-zz2mufZDA?V?l&1wy0k%gUi2wiq delta 84 zcmV-a0IUC}45bXPs{}OY0uto{2nrhD0tz5*wHm;UhaR_YE`B&A)m?T_53Obh;F9FN q%lp0_DC3I|0T=-w0V=bz-vl%P5;zAB8Vv{m5DF;R-NKZo1yBJeI~}Y5 diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto index 9ad149e775a259dec54246070a28b9c9b1112c06..e31ee5c5a5c0f12de0e27dd8e1a7cedd6ca6c05f 100644 GIT binary patch delta 45 zcmV+|0Mh^43)>5@w*(*5@w*(+=wHm;UhaR_YE`B&A)m?T_53Obh;F9FN%lp0_DC3K>*8~Is D)$J6w diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto index 0ab9d061bea95f86e51a1d3913502f617bc91d20..a354fc69143d0b63a2887b89a7fa865bb51bedf8 100644 GIT binary patch delta 110 zcmV-!0FnR23&#tvs{}BS0uqh_2nrgB0tz4)TaZ}q!TLyUHY$t$g8IY`o@W94a{K3Q zwHm;UhaR^W0V>{;+XTBY+5c$Ej2y;s33}PIz-`LL3oBp;!-QvtlCp}IKR_NzU;%Ie QFabCT2pR?m0T2pM0U75jssI20 delta 108 zcmV-y0F(d63&jhts{}BQ0uqb@2nrg90tz5*wHm;UhaR_YE`B&A)m?T_53Obh;F9FN z%lp0_DC3J50V>>++XTBWKR_Nz-;>hfmK5TX!{={K7+a87@4@;=ZZ;~5{(}0%U;!`z OI0*vZVNaH2pR?m0T2pM0rnjj^#A|> diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto index 4121043083ed31d5ba5bbe097a6793bf3aa7c1e2..3018becfc6eb0b2876bc18c8a65a0d999180f390 100644 GIT binary patch delta 45 zcmV+|0Mh@&48#nuw*(**92_= D&n*=6 diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt index 76ca569ece..40ea81f97f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt @@ -313,7 +313,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible { timestamp = wallClock, groupId = groupId, update = updateDescription, - isGroupAdd = false, + isNotifiable = false, serverGuid = null ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java index f524d1d36a..814783a057 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java @@ -76,7 +76,7 @@ public final class BlockUnblockDialog { Resources resources = context.getResources(); if (recipient.isGroup()) { - if (SignalDatabase.groups().isActive(recipient.requireGroupId())) { + if (SignalDatabase.groups().isMember(recipient.requireGroupId())) { builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_and_leave_s, recipient.getDisplayName(context))); builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates); builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_leave, ((dialog, which) -> onBlock.run())); @@ -121,7 +121,7 @@ public final class BlockUnblockDialog { Resources resources = context.getResources(); if (recipient.isGroup()) { - if (SignalDatabase.groups().isActive(recipient.requireGroupId())) { + if (SignalDatabase.groups().isMember(recipient.requireGroupId())) { builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context))); builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you); builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt index 50be3f418e..4e93ca8195 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt @@ -97,7 +97,7 @@ fun RecipientTable.getGroupsForBackup(selfAci: ServiceId.ACI): GroupArchiveExpor "${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}", "${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}", "${GroupTable.TABLE_NAME}.${GroupTable.TITLE}", - "${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE}", + "${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER}", "${GroupTable.TABLE_NAME}.${GroupTable.V2_DECRYPTED_GROUP}" ) .from( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt index bd5a19f42c..343ac27246 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt @@ -50,7 +50,7 @@ class GroupArchiveExporter(private val selfAci: ServiceId.ACI, private val curso val extras = RecipientTableCursorUtil.getExtras(cursor) val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE)) - val isActive: Boolean = cursor.requireBoolean(GroupTable.ACTIVE) + val isMember: Boolean = cursor.requireBoolean(GroupTable.IS_MEMBER) val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!) return ArchiveRecipient( @@ -61,7 +61,7 @@ class GroupArchiveExporter(private val selfAci: ServiceId.ACI, private val curso blocked = cursor.requireBoolean(RecipientTable.BLOCKED), hideStory = extras?.hideStory() ?: false, storySendMode = showAsStoryState.toRemote(), - snapshot = decryptedGroup.toRemote(isActive, selfAci), + snapshot = decryptedGroup.toRemote(isMember, selfAci), avatarColor = cursor.requireString(RecipientTable.AVATAR_COLOR)?.let { AvatarColor.deserialize(it) }?.toRemote() ) ) @@ -80,9 +80,9 @@ private fun GroupTable.ShowAsStoryState.toRemote(): Group.StorySendMode { } } -private fun DecryptedGroup.toRemote(isActive: Boolean, selfAci: ServiceId.ACI): Group.GroupSnapshot? { +private fun DecryptedGroup.toRemote(isMember: Boolean, selfAci: ServiceId.ACI): Group.GroupSnapshot? { val selfAciBytes = selfAci.toByteString() - val memberFilter = { m: DecryptedMember -> isActive || m.aciBytes != selfAciBytes } + val memberFilter = { m: DecryptedMember -> isMember || m.aciBytes != selfAciBytes } return Group.GroupSnapshot( title = Group.GroupAttributeBlob(title = this.title), @@ -96,7 +96,8 @@ private fun DecryptedGroup.toRemote(isActive: Boolean, selfAci: ServiceId.ACI): inviteLinkPassword = this.inviteLinkPassword, description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) }, announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED, - members_banned = this.bannedMembers.map { it.toRemote() } + members_banned = this.bannedMembers.map { it.toRemote() }, + terminated = this.terminated ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt index e8ce59f822..d5c3b557bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt @@ -162,6 +162,7 @@ private fun Group.GroupSnapshot.toLocal(operations: GroupsV2Operations.GroupOper description = this.description?.descriptionText ?: "", isAnnouncementGroup = if (this.announcements_only) EnabledState.ENABLED else EnabledState.DISABLED, bannedMembers = this.members_banned.map { it.toLocal() }, + terminated = this.terminated, isPlaceholderGroup = isPlaceholder ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java index d829d4a4ce..818082d291 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java @@ -124,7 +124,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements if (resolvedRecipient.isPresent() && resolvedRecipient.get().isGroup()) { Recipient recipient = resolvedRecipient.get(); - if (SignalDatabase.groups().isActive(recipient.requireGroupId())) { + if (SignalDatabase.groups().isMember(recipient.requireGroupId())) { builder.setTitle(getString(R.string.BlockUnblockDialog_block_and_leave_s, displayName)); builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt index d131980e60..2315ac4f49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt @@ -71,7 +71,11 @@ class CallLogContextMenu( ) } - private fun getVideoCallActionItem(peer: Recipient): ActionItem { + private fun getVideoCallActionItem(peer: Recipient): ActionItem? { + if (peer.isGroup && !peer.isActiveGroup) { + return null + } + // TODO [alex] -- Need group calling disposition to make this correct return ActionItem( iconRes = R.drawable.symbol_video_24, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 126ea7d3ba..4b54231611 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -20,6 +20,7 @@ import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.core.view.doOnPreDraw import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.Navigation import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager @@ -28,6 +29,7 @@ import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.kotlin.subscribeBy +import kotlinx.coroutines.launch import org.signal.core.ui.permissions.Permissions import org.signal.core.util.DimensionUnit import org.signal.core.util.Result @@ -48,6 +50,7 @@ import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.ProgressCardDialogFragment import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment @@ -67,6 +70,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.L import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.TerminatedBannerPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.colors.ColorizerV2 @@ -74,6 +78,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel +import org.thoughtcrime.securesms.groups.ui.EndGroupDialog import org.thoughtcrime.securesms.groups.ui.GroupErrors import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry @@ -147,6 +152,12 @@ class ConversationSettingsFragment : } } + private val endGroupIcon by lazy { + ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_x_circle_24).apply { + colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) + } + } + private val viewModel by viewModels( factoryProducer = { val groupId = args.groupId as? GroupId @@ -268,6 +279,7 @@ class ConversationSettingsFragment : InternalPreference.register(adapter) GroupDescriptionPreference.register(adapter) LegacyGroupPreference.register(adapter) + TerminatedBannerPreference.register(adapter) CallPreference.register(adapter) val recipientId = args.recipientId @@ -330,10 +342,17 @@ class ConversationSettingsFragment : return@configure } + state.withGroupSettingsState { + if (it.isTerminated) { + customPref(TerminatedBannerPreference.Model()) + } + } + customPref( AvatarPreference.Model( recipient = state.recipient, storyViewState = state.storyViewState, + reduceTopMargin = state.isTerminatedGroup, onAvatarClick = { avatar -> val viewAvatarIntent = AvatarPreviewActivity.intentFromRecipientId(requireContext(), state.recipient.id) val viewAvatarTransitionBundle = AvatarPreviewActivity.createTransitionBundle(requireActivity(), avatar) @@ -392,7 +411,7 @@ class ConversationSettingsFragment : ) ) - if (groupState.groupId.isV2) { + if (groupState.groupId.isV2 && !groupState.isTerminated) { customPref( GroupDescriptionPreference.Model( groupId = groupState.groupId, @@ -793,10 +812,15 @@ class ConversationSettingsFragment : if (groupState.canAddToGroup || memberCount > 0) { dividerPref() - sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount))) + val memberHeaderText = if (groupState.isTerminated) { + resources.getQuantityString(R.plurals.ConversationSettingsFragment__d_former_members, memberCount, memberCount) + } else { + resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount) + } + sectionHeaderPref(DSLSettingsText.from(memberHeaderText)) } - if (groupState.canAddToGroup && !state.isDeprecatedOrUnregistered) { + if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) { customPref( LargeIconClickPreference.Model( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members), @@ -851,14 +875,14 @@ class ConversationSettingsFragment : ) } - if (state.recipient.isPushV2Group) { + if (state.recipient.isPushV2Group && !groupState.isTerminated) { dividerPref() clickPref( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link), summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off), icon = DSLSettingsIcon.from(R.drawable.ic_link_16), - isEnabled = !state.isDeprecatedOrUnregistered, + isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, onClick = { navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId.requireV2().toString())) } @@ -880,7 +904,7 @@ class ConversationSettingsFragment : clickPref( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites), icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16), - isEnabled = !state.isDeprecatedOrUnregistered, + isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, onClick = { startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2())) } @@ -890,7 +914,7 @@ class ConversationSettingsFragment : clickPref( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions), icon = DSLSettingsIcon.from(R.drawable.ic_lock_24), - isEnabled = !state.isDeprecatedOrUnregistered, + isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, onClick = { val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(groupState.groupId) navController.safeNavigate(action) @@ -913,7 +937,46 @@ class ConversationSettingsFragment : } } - if (state.canModifyBlockedState) { + state.withGroupSettingsState { groupState -> + if (groupState.isTerminated) { + dividerPref() + + if (state.isArchived) { + clickPref( + title = DSLSettingsText.from(R.string.ConversationListFragment_unarchive), + icon = DSLSettingsIcon.from(R.drawable.symbol_archive_up_24), + onClick = { + viewModel.toggleArchive() + } + ) + } else { + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__archive_chat), + icon = DSLSettingsIcon.from(R.drawable.symbol_archive_24), + onClick = { + viewModel.toggleArchive() + onToolbarNavigationClicked() + } + ) + } + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__delete_chat, alertTint), + icon = DSLSettingsIcon.from(CoreUiR.drawable.symbol_trash_24, R.color.signal_alert_primary), + onClick = { + val progressDialog = ProgressCardDialogFragment.create(getString(R.string.ConversationFragment_deleting_messages)) + progressDialog.show(parentFragmentManager, null) + lifecycleScope.launch { + viewModel.deleteChat() + progressDialog.dismissAllowingStateLoss() + onToolbarNavigationClicked() + } + } + ) + } + } + + if (state.canModifyBlockedState && !state.isTerminatedGroup) { state.withRecipientSettingsState { dividerPref() } @@ -1005,6 +1068,50 @@ class ConversationSettingsFragment : ) } } + + state.withGroupSettingsState { groupState -> + if (groupState.isTerminated) { + dividerPref() + + val reportSpamTint = R.color.signal_alert_primary + clickPref( + title = DSLSettingsText.from(R.string.ConversationFragment_report_spam, ContextCompat.getColor(requireContext(), reportSpamTint)), + icon = DSLSettingsIcon.from(R.drawable.symbol_spam_24, reportSpamTint), + onClick = { + BlockUnblockDialog.showReportSpamFor( + requireContext(), + viewLifecycleOwner.lifecycle, + state.recipient, + { + viewModel + .onReportSpam() + .subscribeBy { + Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show() + onToolbarNavigationClicked() + } + .addTo(lifecycleDisposable) + }, + null + ) + } + ) + } + } + + state.withGroupSettingsState { groupState -> + if (groupState.canEndGroup && RemoteConfig.groupTerminateSend) { + dividerPref() + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__end_group, if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint), + icon = DSLSettingsIcon.from(endGroupIcon), + isEnabled = !state.isDeprecatedOrUnregistered, + onClick = { + EndGroupDialog.show(requireActivity(), groupState.groupId.requireV2(), groupState.groupTitle) + } + ) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt index 0f7b20dc43..c92b688d30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -222,9 +222,18 @@ class ConversationSettingsRepository( return liveGroup.getMembershipCountDescription(context.resources) } - fun getExternalPossiblyMigratedGroupRecipientId(groupId: GroupId, consumer: (RecipientId) -> Unit) { - SignalExecutors.BOUNDED.execute { - consumer(Recipient.externalPossiblyMigratedGroup(groupId).id) - } + @WorkerThread + fun isArchived(recipientId: RecipientId): Boolean { + return SignalDatabase.threads.isArchived(recipientId) + } + + @WorkerThread + fun setArchived(threadId: Long, archived: Boolean) { + SignalDatabase.threads.setArchived(setOf(threadId), archived) + } + + @WorkerThread + fun deleteChat(threadId: Long) { + SignalDatabase.threads.deleteConversation(threadId) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt index ba5936c6df..027a0a1e47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt @@ -20,6 +20,7 @@ data class ConversationSettingsState( val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(), val disappearingMessagesLifespan: Int = 0, val canModifyBlockedState: Boolean = false, + val isArchived: Boolean = false, val sharedMedia: List = emptyList(), val sharedMediaIds: List = listOf(), val displayInternalRecipientDetails: Boolean = false, @@ -29,6 +30,7 @@ data class ConversationSettingsState( ) { val isLoaded: Boolean = recipient != Recipient.UNKNOWN && sharedMediaLoaded && specificSettingsState.isLoaded + val isTerminatedGroup: Boolean = (specificSettingsState as? SpecificSettingsState.GroupSettingsState)?.isTerminated == true fun withRecipientSettingsState(consumer: (SpecificSettingsState.RecipientSettingsState) -> Unit) { if (specificSettingsState is SpecificSettingsState.RecipientSettingsState) { @@ -72,6 +74,8 @@ sealed class SpecificSettingsState { val isSelfAdmin: Boolean = false, val canAddToGroup: Boolean = false, val canEditGroupAttributes: Boolean = false, + val isActive: Boolean = false, + val isTerminated: Boolean = false, val canLeave: Boolean = false, val canShowMoreGroupMembers: Boolean = false, val groupMembersExpanded: Boolean = false, @@ -88,6 +92,8 @@ sealed class SpecificSettingsState { val canSetOwnMemberLabel: Boolean = false ) : SpecificSettingsState() { + val canEndGroup: Boolean get() = isActive && groupId.isV2 && isSelfAdmin + override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded override fun requireGroupSettingsState(): GroupSettingsState = this diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt index 07d00036bb..dae71a9646 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt @@ -144,6 +144,26 @@ sealed class ConversationSettingsViewModel( disposable.clear() } + fun toggleArchive() { + val state = store.state + if (state.threadId > 0) { + val newArchived = !state.isArchived + store.update { it.copy(isArchived = newArchived) } + viewModelScope.launch(SignalDispatchers.IO) { + repository.setArchived(state.threadId, newArchived) + } + } + } + + suspend fun deleteChat() { + withContext(SignalDispatchers.IO) { + val threadId = store.state.threadId + if (threadId > 0) { + repository.deleteChat(threadId) + } + } + } + private class RecipientSettingsViewModel( private val recipientId: RecipientId, private val callMessageIds: LongArray, @@ -299,21 +319,21 @@ sealed class ConversationSettingsViewModel( store.update { it.copy(storyViewState = storyViewState) } } - val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a } - store.update(recipientAndIsActive) { (recipient, isActive), state -> + store.update(liveGroup.groupRecipient) { recipient, state -> state.copy( recipient = recipient, buttonStripState = ButtonStripPreference.State( isMessageAvailable = callMessageIds.isNotEmpty(), - isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive, + isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup, isAudioAvailable = false, isAudioSecure = recipient.isPushV2Group, isMuted = recipient.isMuted, isMuteAvailable = true, isSearchAvailable = callMessageIds.isEmpty(), - isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive && !SignalStore.story.isFeatureDisabled + isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup && !SignalStore.story.isFeatureDisabled ), canModifyBlockedState = RecipientUtil.isBlockable(recipient), + isArchived = repository.isArchived(recipient.id), specificSettingsState = state.requireGroupSettingsState().copy( legacyGroupState = getLegacyGroupState() ) @@ -398,11 +418,20 @@ sealed class ConversationSettingsViewModel( store.update(liveGroup.isActive) { isActive, state -> state.copy( specificSettingsState = state.requireGroupSettingsState().copy( + isActive = isActive, canLeave = isActive && groupId.isPush ) ) } + store.update(liveGroup.isTerminated) { isTerminated, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + isTerminated = isTerminated + ) + ) + } + store.update(liveGroup.title) { title, state -> state.copy( specificSettingsState = state.requireGroupSettingsState().copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt index 1064ac4b68..c844d40a57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt @@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.groups.GroupAccessControl import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.LiveGroup import org.thoughtcrime.securesms.util.SingleLiveEvent +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil import org.thoughtcrime.securesms.util.livedata.Store class PermissionsSettingsViewModel( @@ -22,8 +23,8 @@ class PermissionsSettingsViewModel( val events: LiveData = internalEvents init { - store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state -> - state.copy(selfCanEditSettings = isSelfAdmin) + store.update(LiveDataUtil.combineLatest(liveGroup.isSelfAdmin, liveGroup.isActive) { admin, active -> admin && active }) { canEdit, state -> + state.copy(selfCanEditSettings = canEdit) } store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt index 3ff63e0512..24b241cb70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences import android.view.View +import android.view.ViewGroup import androidx.core.view.ViewCompat +import org.signal.core.util.dp import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.view.AvatarView import org.thoughtcrime.securesms.badges.BadgeImageView @@ -25,6 +27,7 @@ object AvatarPreference { class Model( val recipient: Recipient, val storyViewState: StoryViewState, + val reduceTopMargin: Boolean = false, val onAvatarClick: (AvatarView) -> Unit, val onBadgeClick: (Badge) -> Unit ) : PreferenceModel() { @@ -35,7 +38,8 @@ object AvatarPreference { override fun areContentsTheSame(newItem: Model): Boolean { return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) && - storyViewState == newItem.storyViewState + storyViewState == newItem.storyViewState && + reduceTopMargin == newItem.reduceTopMargin } } @@ -49,6 +53,10 @@ object AvatarPreference { } override fun bind(model: Model) { + (itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.let { + it.topMargin = if (model.reduceTopMargin) 0.dp else 40.dp + } + if (model.recipient.isSelf) { badge.setBadge(null) badge.setOnClickListener(null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/TerminatedBannerPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/TerminatedBannerPreference.kt new file mode 100644 index 0000000000..18442b24b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/TerminatedBannerPreference.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +object TerminatedBannerPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_terminated_banner)) + } + + class Model : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean = true + override fun areContentsTheSame(newItem: Model): Boolean = true + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + override fun bind(model: Model) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 6ef5d9a6f7..5fd5f6bc74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -426,7 +426,7 @@ class ContactSearchPagedDataSource( } private fun canSendToGroup(groupRecord: GroupRecord?): Boolean { - if (groupRecord == null) return false + if (groupRecord == null || groupRecord.isTerminated) return false return if (groupRecord.isAnnouncementGroup) { groupRecord.isAdmin(Recipient.self()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt index 0ee6271c47..a4ac3dca8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt @@ -51,7 +51,7 @@ internal object ConversationOptionsMenu { canShowAsBubble, isActiveGroup, isActiveV2Group, - isInActiveGroup, + isInactiveGroup, hasActiveGroupCall, distributionType, threadId, @@ -104,12 +104,12 @@ internal object ConversationOptionsMenu { if (isPushAvailable) { if (recipient.expiresInSeconds > 0) { - if (!isInActiveGroup) { + if (!isInactiveGroup) { menuInflater.inflate(R.menu.conversation_expiring_on, menu) } callback.showExpiring(recipient) } else { - if (!isInActiveGroup) { + if (!isInactiveGroup) { menuInflater.inflate(R.menu.conversation_expiring_off, menu) } callback.clearExpiring() @@ -150,6 +150,11 @@ internal object ConversationOptionsMenu { hideMenuItem(menu, R.id.menu_mute_notifications) } + if (recipient.isGroup && isInactiveGroup) { + hideMenuItem(menu, R.id.menu_mute_notifications) + hideMenuItem(menu, R.id.menu_unmute_notifications) + } + if (recipient.isBlocked) { if (isPushAvailable) { hideMenuItem(menu, R.id.menu_call_secure) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index ae36933ad7..26f7c86d18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -171,11 +171,11 @@ public final class MenuState { hasPollTerminate = true; } - if (!messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) { + if (!messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift && !conversationRecipient.isInactiveGroup()) { canPinMessage = true; } - if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) { + if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift && !conversationRecipient.isInactiveGroup()) { canUnpinMessage = true; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index fa2cc6ea8d..3990f9200b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -573,10 +573,10 @@ class ConversationAdapterV2( } if (groupInfo.fullMemberCount > 0 || groupInfo.pendingMemberCount > 0) { - if (groupInfo.fullMemberCount == 1 && recipient.isActiveGroup) { + if (groupInfo.fullMemberCount == 1 && groupInfo.isMember) { conversationBanner.hideUnverifiedNameSubtitle() } - setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, recipient) + setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, groupInfo.isMember, recipient) } else { conversationBanner.hideSubtitle() } @@ -640,10 +640,10 @@ class ConversationAdapterV2( conversationBanner.updateOutlineBoxSize() } - private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List, recipient: Recipient) { + private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List, isMember: Boolean, recipient: Recipient) { val names = members.map { member -> member.getDisplayName(context) } val otherMembers = if (size > 3) context.resources.getQuantityString(R.plurals.MessageRequestProfileView_other_members, size - 3, size - 3) else null - val membersSubtitle = if (recipient.isActiveGroup) { + val membersSubtitle = if (isMember) { when (names.size) { 0 -> context.getString(R.string.MessageRequestProfileView_group_members_zero) 1 -> context.getString(R.string.MessageRequestProfileView_group_members_one_and_you, names[0]) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt index 2b21d023c0..bf2c81a2f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt @@ -10,6 +10,7 @@ import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.concurrent.SimpleTask import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.recipients.Recipient @@ -94,6 +95,18 @@ object ConversationDialogs { .show() } + fun displayTerminatedGroupSendFailedDialog(context: Context, messageRecord: MessageRecord) { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.conversation_activity__send_failed_group_ended) + .setNegativeButton(R.string.ConversationFragment_delete_for_me) { _, _ -> + SignalExecutors.BOUNDED.execute { + SignalDatabase.messages.deleteMessage(messageRecord.id) + } + } + .setPositiveButton(android.R.string.ok, null) + .show() + } + fun displayDeletionFailedDialog(context: Context, messageRecord: MessageRecord, canRetry: Boolean) { if (canRetry) { MaterialAlertDialogBuilder(context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 442263a75d..a25a4f0571 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -99,6 +99,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -1171,6 +1172,26 @@ class ConversationFragment : viewLifecycleOwner.lifecycle.addObserver(LastScrolledPositionUpdater(adapter, layoutManager, viewModel)) + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + var wasTerminated: Boolean? = null + viewModel + .groupRecordFlow + .collect { record -> + val isTerminated = record.isTerminated + if (wasTerminated == false && isTerminated) { + val terminatedByRecipientId = record.terminatedByRecipientId + if (terminatedByRecipientId == null || terminatedByRecipientId != Recipient.self().id) { + val context = context ?: return@collect + val adminName = terminatedByRecipientId?.let { Recipient.resolved(it).getDisplayName(context) } + withContext(Dispatchers.Main) { + TerminatedGroupBottomSheetDialog.show(childFragmentManager, adminName) + } + } + } + wasTerminated = isTerminated + } + } + disposables += viewModel.recipient .observeOn(AndroidSchedulers.mainThread()) .distinctUntilChanged { r1, r2 -> r1 === r2 || r1.hasSameContent(r2) } @@ -1492,6 +1513,7 @@ class ConversationFragment : inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized) args.isIncognito -> disabledInputView.showAsIncognito() !inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState) + inputReadyState.isTerminatedGroup -> disabledInputView.showAsTerminatedGroup() inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember() inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember() inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly() @@ -3424,7 +3446,9 @@ class ConversationFragment : override fun onMessageWithErrorClicked(messageRecord: MessageRecord) { val recipientId = viewModel.recipientSnapshot?.id ?: return - if (messageRecord.isFailedAdminDelete) { + if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) { + ConversationDialogs.displayTerminatedGroupSendFailedDialog(requireContext(), messageRecord) + } else if (messageRecord.isFailedAdminDelete) { val canRetry = MessageConstraintsUtil.isValidAdminDeleteSend(message = messageRecord, currentTime = System.currentTimeMillis(), isAdmin = conversationGroupViewModel.isAdmin(), isResend = true) if (messageRecord.isIdentityMismatchFailure && canRetry) { SafetyNumberBottomSheet 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 f4068858ed..8dd32070ce 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 @@ -183,7 +183,7 @@ class ConversationViewModel( val messageRequestState: MessageRequestState get() = hasMessageRequestStateSubject.value ?: MessageRequestState() - private val groupRecordFlow: Flow + val groupRecordFlow: Flow private val refreshIdentityRecords: Subject = PublishSubject.create() private val identityRecordsStore: RxStore = RxStore(IdentityRecordsState()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt index 459c300c4d..fbc9a2ab3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt @@ -44,6 +44,7 @@ class DisabledInputView @JvmOverloads constructor( private var expiredOrUnauthorized: View? = null private var messageRequestView: MessageRequestsBottomView? = null private var noLongerAMember: View? = null + private var terminatedGroup: View? = null private var requestingGroup: View? = null private var announcementGroupOnly: TextView? = null private var inviteToSignal: View? = null @@ -126,6 +127,13 @@ class DisabledInputView @JvmOverloads constructor( ) } + fun showAsTerminatedGroup() { + terminatedGroup = show( + existingView = terminatedGroup, + create = { inflater.inflate(R.layout.conversation_group_terminated, this, false) } + ) + } + fun showAsRequestingMember() { requestingGroup = show( existingView = requestingGroup, @@ -216,6 +224,7 @@ class DisabledInputView @JvmOverloads constructor( messageRequestView?.hideBusy() messageRequestView = null noLongerAMember = null + terminatedGroup = null requestingGroup = null announcementGroupOnly = null incognitoView = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt index e723a35b7b..3c2cb6c6ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt @@ -30,7 +30,8 @@ class InputReadyState( } val isAnnouncementGroup: Boolean? = groupRecord?.isAnnouncementGroup - val isActiveGroup: Boolean? = if (selfMemberLevel == null) null else selfMemberLevel != GroupTable.MemberLevel.NOT_A_MEMBER + val isActiveGroup: Boolean? = groupRecord?.isActive + val isTerminatedGroup: Boolean = groupRecord?.isTerminated == true val isAdmin: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.ADMINISTRATOR) val isRequestingMember: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.REQUESTING_MEMBER) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt index 58ffc112ce..851da7703e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt @@ -37,6 +37,11 @@ object PinSendUtil { if (groupId != null) { val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull() + + if (groupRecord != null && !groupRecord.isActive) { + throw UndeliverableMessageException("Cannot pin messages in an inactive group!") + } + if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) { throw UndeliverableMessageException("Non-admins cannot pin messages!") } @@ -83,6 +88,11 @@ object PinSendUtil { val groupId = if (threadRecipient.isPushV2Group) threadRecipient.requireGroupId().requireV2() else null if (groupId != null) { val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull() + + if (groupRecord != null && !groupRecord.isActive) { + throw UndeliverableMessageException("Cannot unpin messages in an inactive group!") + } + if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) { throw UndeliverableMessageException("Non-admins cannot pin messages!") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt new file mode 100644 index 0000000000..b57d1d7183 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.R + +/** + * Shown when a group is terminated while the user is actively viewing the conversation. + */ +class TerminatedGroupBottomSheetDialog : ComposeBottomSheetDialogFragment() { + + companion object { + private const val ARG_ADMIN_NAME = "admin_name" + + fun show(fragmentManager: FragmentManager, adminName: String?) { + TerminatedGroupBottomSheetDialog() + .apply { arguments = bundleOf(ARG_ADMIN_NAME to adminName) } + .show(fragmentManager, "terminated_group_sheet") + } + } + + @Composable + override fun SheetContent() { + TerminatedGroupSheetContent( + adminName = requireArguments().getString(ARG_ADMIN_NAME), + onOkClick = { dismissAllowingStateLoss() } + ) + } +} + +@Composable +private fun TerminatedGroupSheetContent(adminName: String?, onOkClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + BottomSheets.Handle() + + Text( + text = if (adminName != null) { + stringResource(R.string.TerminatedGroupBottomSheet__s_ended_the_group, adminName) + } else { + stringResource(R.string.TerminatedGroupBottomSheet__the_group_has_been_ended) + }, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 24.dp, bottom = 10.dp) + ) + + Text( + text = stringResource(R.string.TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 32.dp) + ) + + Buttons.LargeTonal( + onClick = onOkClick, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 24.dp) + ) { + Text(text = stringResource(R.string.TerminatedGroupBottomSheet__okay)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index daef59f7c3..32ebd76aa2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -34,7 +34,6 @@ import org.signal.core.util.requireLong import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString import org.signal.core.util.select -import org.signal.core.util.toInt import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.signal.libsignal.zkgroup.InvalidInputException @@ -103,7 +102,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : const val AVATAR_CONTENT_TYPE = "avatar_content_type" const val AVATAR_DIGEST = "avatar_digest" const val TIMESTAMP = "timestamp" - const val ACTIVE = "active" + const val IS_MEMBER = "active" + const val TERMINATED_BY = "terminated_by" const val MMS = "mms" const val EXPECTED_V2_ID = "expected_v2_id" const val UNMIGRATED_V1_MEMBERS = "unmigrated_v1_members" @@ -133,17 +133,18 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : $AVATAR_CONTENT_TYPE TEXT DEFAULT NULL, $AVATAR_DIGEST BLOB DEFAULT NULL, $TIMESTAMP INTEGER DEFAULT 0, - $ACTIVE INTEGER DEFAULT 1, - $MMS INTEGER DEFAULT 0, - $V2_MASTER_KEY BLOB DEFAULT NULL, - $V2_REVISION BLOB DEFAULT NULL, - $V2_DECRYPTED_GROUP BLOB DEFAULT NULL, - $EXPECTED_V2_ID TEXT UNIQUE DEFAULT NULL, - $UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL, - $DISTRIBUTION_ID TEXT UNIQUE DEFAULT NULL, - $SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code}, + $IS_MEMBER INTEGER DEFAULT 1, + $MMS INTEGER DEFAULT 0, + $V2_MASTER_KEY BLOB DEFAULT NULL, + $V2_REVISION BLOB DEFAULT NULL, + $V2_DECRYPTED_GROUP BLOB DEFAULT NULL, + $EXPECTED_V2_ID TEXT UNIQUE DEFAULT NULL, + $UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL, + $DISTRIBUTION_ID TEXT UNIQUE DEFAULT NULL, + $SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code}, $LAST_FORCE_UPDATE_TIMESTAMP INTEGER DEFAULT 0, - $GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0 + $GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0, + $TERMINATED_BY INTEGER DEFAULT 0 ) """ @@ -160,7 +161,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : AVATAR_CONTENT_TYPE, AVATAR_DIGEST, TIMESTAMP, - ACTIVE, + IS_MEMBER, + TERMINATED_BY, MMS, V2_MASTER_KEY, V2_REVISION, @@ -348,7 +350,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : FROM $TABLE_NAME INNER JOIN ${MembershipTable.TABLE_NAME} ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID - WHERE $TABLE_NAME.$ACTIVE = 1 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where}) + WHERE $TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where}) GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} ORDER BY $TITLE COLLATE NOCASE ASC """ @@ -404,9 +406,9 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : } query = if (includeInactive) { - "($searchQuery) AND ($TABLE_NAME.$ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))" + "($searchQuery) AND ($TABLE_NAME.$IS_MEMBER = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))" } else { - "($searchQuery) AND $TABLE_NAME.$ACTIVE = ?" + "($searchQuery) AND $TABLE_NAME.$IS_MEMBER = ? AND $TABLE_NAME.$TERMINATED_BY = 0" } queryArgs = buildArgs(*searchTokens.toTypedArray(), 1) @@ -495,8 +497,11 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : } if (!includeInactive) { - query += " AND $TABLE_NAME.$ACTIVE = ?" + query += " AND $TABLE_NAME.$IS_MEMBER = ?" args = appendArg(args, "1") + + query += " AND $TABLE_NAME.$TERMINATED_BY = ?" + args = appendArg(args, "0") } return readableDatabase @@ -522,22 +527,23 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : return Reader(cursor) } - fun getInactiveGroups(): Reader { - val query = SqlUtil.buildQuery("$TABLE_NAME.$ACTIVE = ?", false.toInt()) - val select = "${joinedGroupSelect()} WHERE ${query.where}" - - return Reader(readableDatabase.query(select, query.whereArgs)) - } - fun getActiveGroupCount(): Int { return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) - .where("$ACTIVE = ?", 1) + .where("$IS_MEMBER = ? AND $TERMINATED_BY = ?", 1, 0) .run() .readToSingleInt(0) } + fun setTerminatedBy(groupId: GroupId, recipientId: RecipientId) { + writableDatabase + .update(TABLE_NAME) + .values(TERMINATED_BY to recipientId.serialize()) + .where("$GROUP_ID = ?", groupId) + .run() + } + @WorkerThread fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List { return if (groupId.isV2) { @@ -688,14 +694,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : values.put(TIMESTAMP, System.currentTimeMillis()) if (groupId.isV2) { - values.put(ACTIVE, if (groupState != null && gv2GroupActive(groupState)) 1 else 0) + values.put(IS_MEMBER, if (groupState != null && isGroupMember(groupState)) 1 else 0) + values.put(TERMINATED_BY, if (groupState?.terminated == true) -1 else 0) values.put(DISTRIBUTION_ID, DistributionId.create().toString()) values.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements?.expirationMs ?: 0) } else if (groupId.isV1) { - values.put(ACTIVE, 1) + values.put(IS_MEMBER, 1) values.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString()) } else { - values.put(ACTIVE, 1) + values.put(IS_MEMBER, 1) } if (groupMasterKey != null) { @@ -793,7 +800,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : contentValues.put(TITLE, title) contentValues.put(V2_REVISION, decryptedGroup.revision) contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.encode()) - contentValues.put(ACTIVE, if (gv2GroupActive(decryptedGroup)) 1 else 0) + contentValues.put(IS_MEMBER, if (isGroupMember(decryptedGroup)) 1 else 0) + contentValues.put(TERMINATED_BY, if (decryptedGroup.terminated) -1 else 0) if (receivedGroupSendEndorsements != null) { contentValues.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements.expirationMs) @@ -938,10 +946,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : return record.isPresent && record.get().isActive } - fun setActive(groupId: GroupId, active: Boolean) { + fun isMember(groupId: GroupId): Boolean { + val record = getGroup(groupId) + return record.isPresent && record.get().isMember + } + + fun setMember(groupId: GroupId, isMember: Boolean) { writableDatabase .update(TABLE_NAME) - .values(ACTIVE to if (active) 1 else 0) + .values(IS_MEMBER to if (isMember) 1 else 0) .where("$GROUP_ID = ?", groupId) .run() } @@ -1079,6 +1092,12 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : } override fun remapRecipient(fromId: RecipientId, toId: RecipientId) { + writableDatabase + .update(TABLE_NAME) + .values(TERMINATED_BY to toId.toLong()) + .where("$TERMINATED_BY = ?", fromId.toLong()) + .run() + // Remap all recipients that would not result in conflicts writableDatabase.execSQL( """ @@ -1139,7 +1158,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : avatarId = cursor.requireLong(AVATAR_ID), avatarKey = cursor.requireBlob(AVATAR_KEY), avatarContentType = cursor.requireString(AVATAR_CONTENT_TYPE), - isActive = cursor.requireBoolean(ACTIVE), + isMember = cursor.requireBoolean(IS_MEMBER), + terminatedBy = cursor.requireLong(TERMINATED_BY), avatarDigest = cursor.requireBlob(AVATAR_DIGEST), isMms = cursor.requireBoolean(MMS), groupMasterKeyBytes = cursor.requireBlob(V2_MASTER_KEY), @@ -1345,7 +1365,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : ) AS active_timestamp FROM $TABLE_NAME INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID WHERE - $TABLE_NAME.$ACTIVE = 1 AND + $TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND ( $SHOW_AS_STORY_STATE = ${ShowAsStoryState.ALWAYS.code} OR ( @@ -1394,7 +1414,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : } } - private fun gv2GroupActive(decryptedGroup: DecryptedGroup): Boolean { + private fun isGroupMember(decryptedGroup: DecryptedGroup): Boolean { val aci = SignalStore.account.requireAci() return decryptedGroup.members.findMemberByAci(aci).isPresent || diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 38fe4b2954..11c12047fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -2861,7 +2861,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } - val silent = (MessageTypes.isGroupUpdate(type) && !retrieved.isGroupAdd) || + val silent = (MessageTypes.isGroupUpdate(type) && !retrieved.isNotifiable) || retrieved.type == MessageType.IDENTITY_DEFAULT || retrieved.type == MessageType.IDENTITY_VERIFIED || retrieved.type == MessageType.IDENTITY_UPDATE diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 6ddef88d7b..e4b78acc42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -3745,7 +3745,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val threadDatabase = threads val recipientsWithinInteractionThreshold: MutableSet = LinkedHashSet() - threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1, false)).use { reader -> + threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1)).use { reader -> var record: ThreadRecord? = reader.getNext() while (record != null && record.date > lastInteractionThreshold) { @@ -4830,7 +4830,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da SELECT 1 FROM ${GroupTable.MembershipTable.TABLE_NAME} INNER JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} = ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.GROUP_ID} - WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0 + WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0 ) """ val E164_SEARCH = "(($PHONE_NUMBER_SHARING != ${PhoneNumberSharingState.DISABLED.id} OR $SYSTEM_CONTACT_URI NOT NULL) AND $E164 GLOB ?)" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 608c196da9..d886c96c7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -873,7 +873,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa var where = "" if (!includeInactiveGroups) { - where += "$MEANINGFUL_MESSAGES != 0 AND (${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} IS NULL OR ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1)" + where += "$MEANINGFUL_MESSAGES != 0 AND (${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} IS NULL OR (${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0))" } else { where += "$MEANINGFUL_MESSAGES != 0" } @@ -922,8 +922,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa return readableDatabase.rawQuery(query, null) } - fun getRecentPushConversationList(limit: Int, includeInactiveGroups: Boolean): Cursor { - val activeGroupQuery = if (!includeInactiveGroups) " AND " + GroupTable.TABLE_NAME + "." + GroupTable.ACTIVE + " = 1" else "" + fun getRecentPushConversationList(limit: Int): Cursor { val where = """ $MEANINGFUL_MESSAGES != 0 AND ( @@ -931,7 +930,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa OR ( ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} NOT NULL AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0 - $activeGroupQuery + AND ${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1 + AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0 ) ) """ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 360992258e..c21e8b5724 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -161,6 +161,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNo import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchivedColumn import org.thoughtcrime.securesms.database.helpers.migration.V306_AddRemoteDeletedColumn import org.thoughtcrime.securesms.database.helpers.migration.V308_AddBackRemoteDeletedColumn +import org.thoughtcrime.securesms.database.helpers.migration.V309_GroupTerminatedColumnMigration import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -329,10 +330,11 @@ object SignalDatabaseMigrations { 305 to V305_AddStoryArchivedColumn, 306 to V306_AddRemoteDeletedColumn, // 307 to V307_RemoveRemoteDeletedColumn - Removed due to unsolvable OOM crashes. [TODO]: Attempt to fix in the future - 308 to V308_AddBackRemoteDeletedColumn + 308 to V308_AddBackRemoteDeletedColumn, + 309 to V309_GroupTerminatedColumnMigration ) - const val DATABASE_VERSION = 308 + const val DATABASE_VERSION = 309 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_GroupTerminatedColumnMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_GroupTerminatedColumnMigration.kt new file mode 100644 index 0000000000..9977e617e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_GroupTerminatedColumnMigration.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Adds 'terminated_by' that stores the recipient id of the + * admin who terminated the group, -1 if unknown, 0 if not terminated. + */ +@Suppress("ClassName") +object V309_GroupTerminatedColumnMigration : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE groups ADD COLUMN terminated_by INTEGER DEFAULT 0") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt index 5c93936c6f..7610d84b16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt @@ -24,7 +24,8 @@ class GroupRecord( val avatarId: Long, val avatarKey: ByteArray?, val avatarContentType: String?, - val isActive: Boolean, + val isMember: Boolean, + private val terminatedBy: Long = 0, val avatarDigest: ByteArray?, val isMms: Boolean, groupMasterKeyBytes: ByteArray?, @@ -61,6 +62,15 @@ class GroupRecord( } } + val isTerminated: Boolean + get() = terminatedBy != 0L + + val terminatedByRecipientId: RecipientId? + get() = if (terminatedBy > 0) RecipientId.from(terminatedBy) else null + + val isActive: Boolean + get() = isMember && !isTerminated + val description: String get() = v2GroupProperties?.decryptedGroup?.description ?: "" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt index 9167535e74..90869a02bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt @@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupMembershipAccessLevelChan import org.thoughtcrime.securesms.backup.v2.proto.GroupNameUpdate import org.thoughtcrime.securesms.backup.v2.proto.GroupSelfInvitationRevokedUpdate import org.thoughtcrime.securesms.backup.v2.proto.GroupSequenceOfRequestsAndCancelsUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupTerminateChangeUpdate import org.thoughtcrime.securesms.backup.v2.proto.GroupUnknownInviteeUpdate import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel import org.thoughtcrime.securesms.backup.v2.proto.SelfInvitedOtherUserToGroupUpdate @@ -155,6 +156,7 @@ object GroupsV2UpdateMessageConverter { translateAnnouncementGroupChange(change, editorUnknown, updates) translatePromotePendingPniAci(selfIds, change, editorUnknown, updates) translateMemberRemovals(selfIds, change, editorUnknown, updates) + translateTerminateGroup(change, editorUnknown, updates) if (updates.isEmpty()) { translateUnknownChange(change, editorUnknown, updates) } @@ -684,6 +686,20 @@ object GroupsV2UpdateMessageConverter { } } + @JvmStatic + fun translateTerminateGroup(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.terminateGroup) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + updates.add( + GroupChangeChatUpdate.Update( + groupTerminateChangeUpdate = GroupTerminateChangeUpdate( + updaterAci = editorAci + ) + ) + ) + } + } + @JvmStatic fun translateUnknownChange(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { updates.add( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index aa85732769..737c8fc56e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberRemovedUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupMembershipAccessLevelChangeUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupNameUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupSelfInvitationRevokedUpdate; +import org.thoughtcrime.securesms.backup.v2.proto.GroupTerminateChangeUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupUnknownInviteeUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel; import org.thoughtcrime.securesms.backup.v2.proto.GroupV2MigrationDroppedMembersUpdate; @@ -220,6 +221,8 @@ final class GroupsV2UpdateMessageProducer { describeGroupExpirationTimerUpdate(update.groupExpirationTimerUpdate, updates); } else if (update.groupSelfInvitationRevokedUpdate != null) { describeGroupSelfInvitationRevokedUpdate(update.groupSelfInvitationRevokedUpdate, updates); + } else if (update.groupTerminateChangeUpdate != null) { + describeGroupTerminateUpdate(update.groupTerminateChangeUpdate, updates); } } @@ -231,6 +234,18 @@ final class GroupsV2UpdateMessageProducer { } } + private void describeGroupTerminateUpdate(@NonNull GroupTerminateChangeUpdate update, @NonNull List updates) { + if (update.updaterAci == null) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_was_terminated), Glyph.X_CIRCLE)); + } else { + if (selfIds.matches(update.updaterAci)) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_terminated_the_group), Glyph.X_CIRCLE)); + } else { + updates.add(updateDescription(R.string.MessageRecord_s_terminated_the_group, update.updaterAci, Glyph.X_CIRCLE)); + } + } + } + private void describeGroupExpirationTimerUpdate(@NonNull GroupExpirationTimerUpdate update, @NonNull List updates) { final int duration = Math.toIntExact(update.expiresInMs / 1000); String time = ExpirationUtil.getExpirationDisplayValue(context, duration); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 24a7154c57..f0e1d59a56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -75,6 +75,23 @@ public final class GroupManager { } } + @WorkerThread + public static void terminateGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) + throws GroupChangeBusyException, GroupChangeFailedException, IOException + { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { + edit.terminateGroup(); + SignalDatabase.groups().setTerminatedBy(groupId, Recipient.self().getId()); + Log.i(TAG, "Terminated group " + groupId); + } catch (GroupInsufficientRightsException e) { + Log.w(TAG, "Insufficient rights to terminate " + groupId, e); + throw new GroupChangeFailedException(e); + } catch (GroupNotAMemberException e) { + Log.w(TAG, "Not a member of " + groupId, e); + throw new GroupChangeFailedException(e); + } + } + @WorkerThread public static void leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId, boolean sendToMembers) throws GroupChangeBusyException, GroupChangeFailedException, IOException @@ -216,7 +233,7 @@ public final class GroupManager { { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.acceptInvite(); - SignalDatabase.groups().setActive(groupId, true); + SignalDatabase.groups().setMember(groupId, true); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 63540d2dbb..60c19d738f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -435,6 +435,13 @@ final class GroupManagerV2 { return commitChangeWithConflictResolution(selfAci, change); } + @WorkerThread + @NonNull GroupManager.GroupActionResult terminateGroup() + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(selfAci, groupOperations.createTerminateGroup()); + } + @WorkerThread void leaveGroup(boolean sendToMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index 91f2e20ac8..f2aa1459d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -137,6 +137,10 @@ public final class LiveGroup { return recipient; } + public LiveData getGroupRecord() { + return groupRecord; + } + public LiveData isSelfAdmin() { return Transformations.map(groupRecord, g -> g.isAdmin(Recipient.self())); } @@ -149,6 +153,14 @@ public final class LiveGroup { return Transformations.map(groupRecord, GroupRecord::isActive); } + public LiveData isTerminated() { + return Transformations.map(groupRecord, GroupRecord::isTerminated); + } + + public LiveData isMember() { + return Transformations.map(groupRecord, GroupRecord::isMember); + } + public LiveData getRecipientIsAdmin(@NonNull RecipientId recipientId) { return LiveDataUtil.mapAsync(groupRecord, g -> g.isAdmin(Recipient.resolved(recipientId))); } @@ -201,11 +213,13 @@ public final class LiveGroup { } public LiveData selfCanEditGroupAttributes() { - return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), LiveGroup::applyAccessControl); + return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), isActive(), + (level, access, active) -> active && applyAccessControl(level, access)); } public LiveData selfCanAddMembers() { - return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), LiveGroup::applyAccessControl); + return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), isActive(), + (level, access, active) -> active && applyAccessControl(level, access)); } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt index 0a1be6d120..53a633dbae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt @@ -98,6 +98,8 @@ class MemberLabelRepository private constructor( suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) { val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false + if (groupRecord.isTerminated) return@withContext false + val memberLevel = groupRecord.memberLevel(recipient) if (groupRecord.memberLabelAccessControl == GroupAccessControl.ONLY_ADMINS) { memberLevel == GroupTable.MemberLevel.ADMINISTRATOR diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/EndGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/EndGroupDialog.kt new file mode 100644 index 0000000000..6311ac3d41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/EndGroupDialog.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.ui + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.SignalProgressDialog +import org.thoughtcrime.securesms.groups.GroupChangeBusyException +import org.thoughtcrime.securesms.groups.GroupChangeException +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupManager +import java.io.IOException + +/** + * Handles the end group flow for admins. Shows a two-step confirmation + * dialog before terminating the group. + */ +object EndGroupDialog { + + private val TAG = Log.tag(EndGroupDialog::class.java) + + @JvmStatic + fun show(activity: FragmentActivity, groupId: GroupId.V2, groupName: String) { + MaterialAlertDialogBuilder(activity) + .setTitle(activity.getString(R.string.EndGroupDialog__end_s, groupName)) + .setMessage(R.string.EndGroupDialog__members_will_no_longer_be_able_to_send) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ -> + showFinalConfirmation(activity, groupId) + } + .show() + } + + private fun showFinalConfirmation(activity: FragmentActivity, groupId: GroupId.V2) { + MaterialAlertDialogBuilder(activity) + .setMessage(R.string.EndGroupDialog__this_will_end_the_group_permanently) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ -> + performEndGroup(activity, groupId) + } + .show() + } + + private fun performEndGroup(activity: FragmentActivity, groupId: GroupId.V2) { + val progressDialog = SignalProgressDialog.show( + context = activity, + message = activity.getString(R.string.EndGroupDialog__ending_group), + indeterminate = true + ) + + activity.lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + try { + GroupManager.terminateGroup(activity, groupId) + GroupChangeResult.SUCCESS + } catch (e: GroupChangeException) { + Log.w(TAG, "Failed to end group", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } catch (e: GroupChangeBusyException) { + Log.w(TAG, "Failed to end group", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } catch (e: IOException) { + Log.w(TAG, "Failed to end group", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } + } + progressDialog.dismiss() + if (!result.isSuccess) { + showRetryDialog(activity, groupId) + } + } + } + + private fun showRetryDialog(activity: FragmentActivity, groupId: GroupId.V2) { + MaterialAlertDialogBuilder(activity) + .setMessage(R.string.EndGroupDialog__ending_the_group_failed) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.EndGroupDialog__try_again) { _, _ -> + performEndGroup(activity, groupId) + } + .show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt index 5dbdcdde9a..8b5c2916e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -116,7 +116,12 @@ class GroupsV2StateProcessor private constructor( return GroupUpdateResult.CONSISTENT_OR_AHEAD } - return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isActive)) { + if (currentLocalState.terminated) { + Log.i(TAG, "$logPrefix Group is terminated, not updating") + return GroupUpdateResult.CONSISTENT_OR_AHEAD + } + + return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isMember)) { InternalUpdateResult.NoUpdateNeeded -> GroupUpdateResult.CONSISTENT_OR_AHEAD is InternalUpdateResult.Updated -> GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_UPDATED, result.updatedLocalState) is InternalUpdateResult.NotAMember -> throw result.exception @@ -232,6 +237,11 @@ class GroupsV2StateProcessor private constructor( return false } + if (currentLocalState.terminated) { + Log.w(TAG, "$logPrefix Ignoring P2P group change because group is terminated") + return false + } + if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) { Log.w(TAG, "$logPrefix Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.") return false @@ -320,8 +330,8 @@ class GroupsV2StateProcessor private constructor( val applyGroupStateDiffResult: AdvanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(remoteGroupStateDiff, targetRevision) val updatedGroupState: DecryptedGroup? = applyGroupStateDiffResult.updatedGroupState - if (groupRecord.map { it.isActive }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) { - Log.w(TAG, "$logPrefix Local state is not active, but server is returning state for us, apply regardless of revision") + if (groupRecord.map { it.isMember }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) { + Log.w(TAG, "$logPrefix Local state is not a member, but server is returning state for us, apply regardless of revision") } else if (updatedGroupState == null || updatedGroupState == remoteGroupStateDiff.previousGroupState) { Log.i(TAG, "$logPrefix Local state is at or later than server revision: ${currentLocalState?.revision ?: "null"}") @@ -436,7 +446,7 @@ class GroupsV2StateProcessor private constructor( } private fun notInGroupAndNotBeingAdded(groupRecord: Optional, signedGroupChange: DecryptedGroupChange): Boolean { - val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isActive + val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isMember val addedAsMember = signedGroupChange .newMembers @@ -517,6 +527,20 @@ class GroupsV2StateProcessor private constructor( saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements) + if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) { + val terminatingChange = groupStateDiff.serverHistory + .mapNotNull { it.change } + .firstOrNull { it.terminateGroup } + + if (terminatingChange != null) { + val editorServiceId = ServiceId.parseOrNull(terminatingChange.editorServiceIdBytes) + if (editorServiceId != null) { + val terminatorRecipientId = RecipientId.from(editorServiceId) + SignalDatabase.groups.setTerminatedBy(groupId, terminatorRecipientId) + } + } + } + if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) { Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder") profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null) @@ -707,7 +731,7 @@ class GroupsV2StateProcessor private constructor( Log.w(TAG, "Failed to insert leave message for $groupId", e) } - SignalDatabase.groups.setActive(groupId, false) + SignalDatabase.groups.setMember(groupId, false) SignalDatabase.groups.remove(groupId, Recipient.self().id) } @@ -772,20 +796,24 @@ class GroupsV2StateProcessor private constructor( } } else { try { - val isGroupAdd = updateDescription - .groupChangeUpdate!! - .updates + val updates = updateDescription.groupChangeUpdate!!.updates + + val isGroupAdd = updates .asSequence() .mapNotNull { it.groupMemberAddedUpdate } .any { serviceIds.matches(it.newMemberAci) } - val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isGroupAdd, serverGuid) + val isGroupTerminate = updates.any { it.groupTerminateChangeUpdate != null } + + val isNotifiable = isGroupAdd || isGroupTerminate + + val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isNotifiable, serverGuid) val insertResult = SignalDatabase.messages.insertMessageInbox(groupMessage) if (insertResult.isPresent) { SignalDatabase.threads.update(insertResult.get().threadId, unarchive = false, allowDeletion = false) - if (isGroupAdd) { + if (isNotifiable) { AppDependencies.messageNotifier.updateNotification(AppDependencies.application) } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java index 872ae332db..6d7e952681 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java @@ -74,6 +74,11 @@ final class ForceUpdateGroupV2WorkerJob extends BaseJob { return; } + if (group.isPresent() && group.get().isTerminated()) { + Log.i(TAG, "Group is terminated, skipping force update."); + return; + } + GroupManager.forceSanityUpdateFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), System.currentTimeMillis()); SignalDatabase.groups().setLastForceUpdateTimestamp(group.get().getId(), System.currentTimeMillis()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java index ab7531e0e9..f9a903d43e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java @@ -89,6 +89,11 @@ final class RequestGroupV2InfoWorkerJob extends BaseJob { return; } + if (group.isPresent() && group.get().isTerminated()) { + Log.i(TAG, "Group is terminated, skipping fetch."); + return; + } + GroupManager.updateGroupFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), toRevision, System.currentTimeMillis()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java index 1e06ab1b40..b26a1b2161 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java @@ -88,7 +88,7 @@ class CameraContactsRepository { List recipients = new ArrayList<>(RECENT_MAX); - try (ThreadTable.Reader threadReader = threadTable.readerFor(threadTable.getRecentPushConversationList(RECENT_MAX, false))) { + try (ThreadTable.Reader threadReader = threadTable.readerFor(threadTable.getRecentPushConversationList(RECENT_MAX))) { ThreadRecord threadRecord; while ((threadRecord = threadReader.getNext()) != null) { recipients.add(threadRecord.getRecipient().resolve()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt index 2433bdfc98..748a3dda7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt @@ -10,7 +10,9 @@ class GroupInfo( val pendingMemberCount: Int = 0, val description: String = "", val hasExistingContacts: Boolean = false, - val membersPreview: List = emptyList() + val membersPreview: List = emptyList(), + val isMember: Boolean = false, + val isTerminated: Boolean = false ) { companion object { @JvmField diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index b11815c2f9..7566ee4e12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -74,11 +74,11 @@ public final class MessageRequestRepository { List membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList()); DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup(); - groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview); + groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview, groupRecord.get().isMember(), groupRecord.get().isTerminated()); } else { List membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList()); - groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview); + groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview, groupRecord.get().isActive(), false); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt index e0c46b8506..1f37033110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt @@ -255,6 +255,13 @@ open class MessageContentProcessor(private val context: Context) { return Gv2PreProcessResult.IGNORE } + if (groupRecord.isPresent && groupRecord.get().isTerminated) { + if (content.dataMessage != null && !content.dataMessage!!.hasSignedGroupChange) { + Log.w(TAG, "Ignoring message from ${senderRecipient.id} because the group is terminated.") + return Gv2PreProcessResult.IGNORE + } + } + if (groupRecord.isPresent && groupRecord.get().isAnnouncementGroup && !groupRecord.get().admins.contains(senderRecipient)) { if (content.dataMessage != null) { if (content.dataMessage!!.hasDisallowedAnnouncementOnlyContent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt index b0fae831b7..671490403e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt @@ -40,7 +40,7 @@ class IncomingMessage( mentions: List = emptyList(), val giftBadge: GiftBadge? = null, val messageExtras: MessageExtras? = null, - val isGroupAdd: Boolean = false, + val isNotifiable: Boolean = false, val poll: Poll? = null ) { @@ -99,7 +99,7 @@ class IncomingMessage( } @JvmStatic - fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, update: GV2UpdateDescription, isGroupAdd: Boolean, serverGuid: String?): IncomingMessage { + fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, update: GV2UpdateDescription, isNotifiable: Boolean, serverGuid: String?): IncomingMessage { val messageExtras = MessageExtras(gv2UpdateDescription = update) val groupContext = MessageGroupContext(update.gv2ChangeDescription!!) @@ -113,7 +113,7 @@ class IncomingMessage( groupContext = groupContext, type = MessageType.GROUP_UPDATE, messageExtras = messageExtras, - isGroupAdd = isGroupAdd + isNotifiable = isNotifiable ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java index 48f683375b..0805f2f6b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java @@ -323,6 +323,7 @@ public class CreateProfileFragment extends LoggingFragment { } } else { Toast.makeText(requireContext(), R.string.CreateProfileActivity_problem_setting_profile, Toast.LENGTH_LONG).show(); + binding.finishButton.cancelSpinning(); } }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java index 83a431bc2d..1ce2edd56c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java @@ -113,6 +113,7 @@ class EditGroupProfileRepository implements EditProfileRepository { return UploadResult.SUCCESS; } catch (GroupChangeException | IOException e) { + Log.d(TAG, "Error updating group details", e); return UploadResult.ERROR_IO; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index f9a75a80c5..50357e5f45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -248,7 +248,7 @@ class Recipient( participantIdsValue.isEmpty() || participantIdsValue.size == 1 && participantIdsValue.contains(self().id) } - /** Whether the group is inactive. Groups become inactive when you leave them. */ + /** Whether the group is inactive. Groups become inactive when you leave them or when the group is terminated. */ val isInactiveGroup: Boolean get() = isGroup && !isActiveGroup diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index abe455f2c0..344e0457cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -11,7 +11,6 @@ import androidx.annotation.WorkerThread; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; @@ -23,6 +22,7 @@ import org.thoughtcrime.securesms.BlockUnblockDialog; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.conversation.colors.ColorizerV2; +import org.signal.storageservice.storage.protos.groups.AccessControl; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.GroupRecord; @@ -53,8 +53,6 @@ import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import java.util.Objects; import java.util.Optional; -import kotlin.Pair; - import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; @@ -92,18 +90,19 @@ final class RecipientDialogViewModel extends ViewModel { if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2() && !recipientIsSelf) { LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId()); - LiveData> localStatus = LiveDataUtil.combineLatest(source.isSelfAdmin(), Transformations.map(source.getGroupLink(), s -> s == null || s.isEnabled()), Pair::new); - LiveData recipientMemberLevel = Transformations.switchMap(recipient, source::getMemberLevel); + adminActionStatus = LiveDataUtil.combineLatest(source.getGroupRecord(), recipient, (group, r) -> { + boolean active = group.isActive(); + boolean localAdmin = group.isAdmin(Recipient.self()); + GroupTable.MemberLevel memberLevel = group.memberLevel(r); + boolean inGroup = memberLevel.isInGroup(); + boolean recipientAdmin = memberLevel == GroupTable.MemberLevel.ADMINISTRATOR; + AccessControl.AccessRequired linkAccess = group.requireV2GroupProperties().getDecryptedGroup().accessControl != null ? group.requireV2GroupProperties().getDecryptedGroup().accessControl.addFromInviteLink + : AccessControl.AccessRequired.UNKNOWN; + boolean isLinkActive = linkAccess == AccessControl.AccessRequired.ANY || linkAccess == AccessControl.AccessRequired.ADMINISTRATOR; - adminActionStatus = LiveDataUtil.combineLatest(localStatus, recipientMemberLevel, (statuses, memberLevel) -> { - boolean localAdmin = statuses.getFirst(); - boolean isLinkActive = statuses.getSecond(); - boolean inGroup = memberLevel.isInGroup(); - boolean recipientAdmin = memberLevel == GroupTable.MemberLevel.ADMINISTRATOR; - - return new AdminActionStatus(inGroup && localAdmin, - inGroup && localAdmin && !recipientAdmin, - inGroup && localAdmin && recipientAdmin, + return new AdminActionStatus(active && inGroup && localAdmin, + active && inGroup && localAdmin && !recipientAdmin, + active && inGroup && localAdmin && recipientAdmin, isLinkActive); }); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java index dcc73a1118..6ec27cd20f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; import org.thoughtcrime.securesms.util.AsynchronousCallback; import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; final class ShareableGroupLinkViewModel extends ViewModel { @@ -27,7 +28,7 @@ final class ShareableGroupLinkViewModel extends ViewModel { this.repository = repository; this.groupLink = liveGroup.getGroupLink(); - this.canEdit = liveGroup.isSelfAdmin(); + this.canEdit = LiveDataUtil.combineLatest(liveGroup.isSelfAdmin(), liveGroup.isActive(), (admin, active) -> admin && active); this.toasts = new SingleLiveEvent<>(); this.busy = new SingleLiveEvent<>(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 242edd72aa..aefa38db27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -286,8 +286,8 @@ public class CommunicationActions { SimpleTask.run(SignalExecutors.BOUNDED, () -> { GroupRecord group = SignalDatabase.groups().getGroup(groupId).orElse(null); - return group != null && group.isActive() ? Recipient.resolved(group.getRecipientId()) - : null; + return group != null && (group.isMember() || group.isTerminated()) ? Recipient.resolved(group.getRecipientId()) + : null; }, recipient -> { if (recipient != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index abe24258a4..ad0807bd7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1303,5 +1303,17 @@ object RemoteConfig { defaultValue = 0, hotSwappable = true ) + + /** + * Whether or not to allow admins to terminate groups. + */ + @JvmStatic + @get:JvmName("groupTerminateSend") + val groupTerminateSend: Boolean by remoteBoolean( + key = "android.groupTerminateSend", + defaultValue = false, + hotSwappable = true + ) + // endregion } diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index 817a1d4c4f..8242bb8123 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -308,6 +308,7 @@ message Group { bytes inviteLinkPassword = 10; bool announcements_only = 12; repeated MemberBanned members_banned = 13; + bool terminated = 14; } message GroupAttributeBlob { @@ -1078,6 +1079,7 @@ message GroupChangeChatUpdate { GroupSequenceOfRequestsAndCancelsUpdate groupSequenceOfRequestsAndCancelsUpdate = 33; GroupExpirationTimerUpdate groupExpirationTimerUpdate = 34; GroupMemberLabelAccessLevelChangeUpdate groupMemberLabelAccessLevelChangeUpdate = 35; + GroupTerminateChangeUpdate groupTerminateChangeUpdate = 36; } } @@ -1134,6 +1136,10 @@ message GroupMemberLabelAccessLevelChangeUpdate { GroupV2AccessLevel accessLevel = 2; } +message GroupTerminateChangeUpdate { + optional bytes updaterAci = 1; +} + message GroupAnnouncementOnlyChangeUpdate { optional bytes updaterAci = 1; bool isAnnouncementOnly = 2; diff --git a/app/src/main/res/drawable/terminated_banner_background.xml b/app/src/main/res/drawable/terminated_banner_background.xml new file mode 100644 index 0000000000..f51ceb8706 --- /dev/null +++ b/app/src/main/res/drawable/terminated_banner_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/layout/conversation_group_terminated.xml b/app/src/main/res/layout/conversation_group_terminated.xml new file mode 100644 index 0000000000..bd4b450b4a --- /dev/null +++ b/app/src/main/res/layout/conversation_group_terminated.xml @@ -0,0 +1,14 @@ + + diff --git a/app/src/main/res/layout/conversation_settings_terminated_banner.xml b/app/src/main/res/layout/conversation_settings_terminated_banner.xml new file mode 100644 index 0000000000..515c964cb6 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_terminated_banner.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45bea3c76e..31cc99854e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -519,6 +519,7 @@ Unable to record audio! You can\'t send messages to this group because you\'re no longer a member. Incognito mode (Labs) + You can\'t send messages because the group was ended. Only %1$s can send messages. admins Message an admin @@ -1882,6 +1883,12 @@ You have left the group. You updated the group. The group was updated. + + The group has been ended + + %1$s ended the group + + You ended the group Outgoing voice call @@ -3616,6 +3623,8 @@ Lock recording of audio attachment Message could not be sent. Check your connection and try again. + + Send failed because the group was ended. You can no longer send and receive messages in this group. Message failed to delete. Check your connection and try again. @@ -6034,6 +6043,19 @@ Get badges for your profile by supporting Signal. Tap on a badge to learn more. This media is not sent yet. + + End group + + This group was ended. + + Archive chat + + Delete chat + + + %1$d former member + %1$d former members + Add members @@ -8505,6 +8527,30 @@ Not now + + End group + + End \"%1$s\"? + + Members will no longer be able to send messages or start calls in the group. They will be notified that you ended the group, and will still have access to message history. + + This will end the group permanently. Are you sure you want to proceed? + + Ending group\u2026 + + Ending the group failed. Check your connection and try again. + + Try again + + + %1$s Ended the Group + + The group has been ended + + You can no longer send and receive messages or calls in this group. + + Okay + Deleting is now synced across all of your devices diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt new file mode 100644 index 0000000000..e10733b67b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.thoughtcrime.securesms.groups.GroupId + +class GroupSettingsStateTest { + + private val v2GroupId = GroupId.v2(org.signal.libsignal.zkgroup.groups.GroupMasterKey(ByteArray(32))) + private val v1GroupId = GroupId.v1(ByteArray(16)) + + private fun createState( + groupId: GroupId = v2GroupId, + isActive: Boolean = true, + isSelfAdmin: Boolean = true, + canLeave: Boolean = true + ): SpecificSettingsState.GroupSettingsState { + return SpecificSettingsState.GroupSettingsState( + groupId = groupId, + isActive = isActive, + isSelfAdmin = isSelfAdmin, + canLeave = canLeave + ) + } + + @Test + fun `canEndGroup is true when active v2 group and self is admin`() { + assertTrue(createState().canEndGroup) + } + + @Test + fun `canEndGroup is false when group is not active`() { + assertFalse(createState(isActive = false).canEndGroup) + } + + @Test + fun `canEndGroup is false when self is not admin`() { + assertFalse(createState(isSelfAdmin = false).canEndGroup) + } + + @Test + fun `canEndGroup is false for v1 group`() { + assertFalse(createState(groupId = v1GroupId).canEndGroup) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt index 93e60f3ed7..899a1395eb 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt @@ -31,6 +31,7 @@ class PermissionsSettingsViewModelTest { ): PermissionsSettingsViewModel { val liveGroup = mockk { every { isSelfAdmin } returns MutableLiveData(false) + every { isActive } returns MutableLiveData(true) every { membershipAdditionAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS) every { attributesAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS) every { isAnnouncementGroup } returns MutableLiveData(false) diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt index fc63193870..d9f1775066 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -107,6 +107,10 @@ class GroupChangeData(private val revision: Int, private val groupOperations: Gr fun changeMemberLabelAccess(access: AccessControl.AccessRequired) { actionsBuilder.modifyMemberLabelAccess = GroupChange.Actions.ModifyMemberLabelAccessControlAction(memberLabelAccess = access) } + + fun terminateGroup() { + actionsBuilder.terminate_group = GroupChange.Actions.TerminateGroupAction() + } } class GroupStateTestData(private val masterKey: GroupMasterKey, private val groupOperations: GroupsV2Operations.GroupOperations? = null) { @@ -134,9 +138,10 @@ class GroupStateTestData(private val masterKey: GroupMasterKey, private val grou requestingMembers: List = emptyList(), inviteLinkPassword: ByteArray = ByteArray(0), disappearingMessageTimer: DecryptedTimer = DecryptedTimer(), - isPlaceholderGroup: Boolean = false + isPlaceholderGroup: Boolean = false, + terminated: Boolean = false ) { - localState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, isPlaceholderGroup) + localState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, isPlaceholderGroup, terminated) groupRecord = groupRecord(masterKey, localState!!, active = active) } @@ -151,9 +156,10 @@ class GroupStateTestData(private val masterKey: GroupMasterKey, private val grou pendingMembers: List = extendGroup?.pendingMembers ?: emptyList(), requestingMembers: List = extendGroup?.requestingMembers ?: emptyList(), inviteLinkPassword: ByteArray = extendGroup?.inviteLinkPassword?.toByteArray() ?: ByteArray(0), - disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer() + disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer(), + terminated: Boolean = extendGroup?.terminated ?: false ) { - serverState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer) + serverState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, terminated = terminated) } fun changeSet(init: ChangeSet.() -> Unit) { @@ -200,6 +206,7 @@ fun groupRecord( avatarKey, avatarContentType, active, + terminatedBy = if (decryptedGroup.terminated) -1L else 0L, avatarDigest, mms, masterKey.serialize(), @@ -223,7 +230,8 @@ fun decryptedGroup( requestingMembers: List = emptyList(), inviteLinkPassword: ByteArray = ByteArray(0), disappearingMessageTimer: DecryptedTimer = DecryptedTimer(), - isPlaceholderGroup: Boolean = false + isPlaceholderGroup: Boolean = false, + terminated: Boolean = false ): DecryptedGroup { return DecryptedGroup( accessControl = accessControl, @@ -237,6 +245,7 @@ fun decryptedGroup( members = members, pendingMembers = pendingMembers, requestingMembers = requestingMembers, - isPlaceholderGroup = isPlaceholderGroup + isPlaceholderGroup = isPlaceholderGroup, + terminated = terminated ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt index 504e2de9c0..05da91a3e5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt @@ -310,4 +310,30 @@ class GroupManagerV2Test_edit { assertThat(other2.labelString, "Other2's label text is preserved").isEqualTo("Bar") } } + + @Test + fun `when admin terminates group, the group state is updated with terminated flag`() { + given { + localState( + revision = 5, + members = listOf( + member(selfAci, role = Member.Role.ADMINISTRATOR), + member(otherAci) + ) + ) + groupChange(6) { + source(selfAci) + terminateGroup() + } + } + + editGroup { + terminateGroup() + } + + then { patchedGroup -> + assertThat(patchedGroup.revision, "Revision updated by one").isEqualTo(6) + assertThat(patchedGroup.terminated, "Group is terminated").isEqualTo(true) + } + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt index c2df10353f..0494cdd223 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.SystemOutLogger import org.whispersystems.signalservice.api.NetworkResult @@ -1050,6 +1051,152 @@ class GroupsV2StateProcessorTest { assertThat(result.updateStatus, "inactive local is still updated given same revision from server").isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) } + @Test + fun `when P2P change terminates group with known editor, then setTerminatedBy is called`() { + val adminAci: ACI = ACI.from(UUID.randomUUID()) + val adminRecipientId = RecipientId.from(200) + + given { + localState( + revision = 5, + members = selfAndOthers + ) + expectTableUpdate = true + } + + every { recipientTable.getAndPossiblyMerge(adminAci, null) } returns adminRecipientId + justRun { groupTable.setTerminatedBy(groupId, adminRecipientId) } + + val signedChange = DecryptedGroupChange( + revision = 6, + editorServiceIdBytes = adminAci.toByteString(), + terminateGroup = true + ) + + val result = processor.updateLocalGroupToRevision( + targetRevision = 6, + timestamp = 0, + signedGroupChange = signedChange, + serverGuid = UUID.randomUUID().toString() + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + assertThat(result.latestServer) + .isNotNull() + .transform { + assertThat(it.terminated, "group should be terminated").isEqualTo(true) + } + + verify { groupTable.setTerminatedBy(groupId, adminRecipientId) } + } + + @Test + fun `when P2P change terminates group without editor, then setTerminatedBy is not called`() { + given { + localState( + revision = 5, + members = selfAndOthers + ) + expectTableUpdate = true + } + + val signedChange = DecryptedGroupChange( + revision = 6, + terminateGroup = true + ) + + val result = processor.updateLocalGroupToRevision( + targetRevision = 6, + timestamp = 0, + signedGroupChange = signedChange, + serverGuid = UUID.randomUUID().toString() + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + assertThat(result.latestServer) + .isNotNull() + .transform { + assertThat(it.terminated, "group should be terminated").isEqualTo(true) + } + + verify(exactly = 0) { groupTable.setTerminatedBy(any(), any()) } + } + + @Test + fun `when force sanity update finds terminated group, then setTerminatedBy is not called because reconstructed change has no editor`() { + given { + localState( + revision = 10, + title = "Title", + members = selfAndOthers + ) + serverState( + revision = 11, + title = "Title", + members = selfAndOthers, + terminated = true + ) + expectTableUpdate = true + } + + val result = processor.forceSanityUpdateFromServer(0) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + assertThat(result.latestServer) + .isNotNull() + .transform { + assertThat(it.terminated, "group should be terminated").isEqualTo(true) + } + + verify(exactly = 0) { groupTable.setTerminatedBy(any(), any()) } + } + + @Test + fun `when group is already terminated, then force sanity update returns consistent`() { + given { + localState( + revision = 10, + members = selfAndOthers, + terminated = true + ) + } + + val result = processor.forceSanityUpdateFromServer(0) + + assertThat(result.updateStatus, "already terminated group should not update") + .isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD) + } + + @Test + fun `when P2P change is received for terminated group, then P2P change is not applied`() { + given { + localState( + revision = 5, + members = selfAndOthers, + terminated = true + ) + changeSet { + } + apiCallParameters(requestedRevision = 5, includeFirst = false) + joinedAtRevision = 0 + } + + val signedChange = DecryptedGroupChange( + revision = 6, + newTitle = DecryptedString("New Title") + ) + + val result = processor.updateLocalGroupToRevision( + targetRevision = 6, + timestamp = 0, + signedGroupChange = signedChange, + serverGuid = UUID.randomUUID().toString() + ) + + assertThat(result.updateStatus, "terminated group should not accept P2P changes") + .isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD) + } + /** * If we get a 500 back from the service we handle it gracefully. */ diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java index 2ca413a6fe..cc2ce1ea7b 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java @@ -50,4 +50,6 @@ public interface ChangeSetModifier { void removeModifyMemberLabels(int i); void clearModifyMemberLabelAccess(); + + void clearTerminateGroup(); } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt index e8971cb9f0..b58a5f969f 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt @@ -118,6 +118,10 @@ internal class DecryptedGroupChangeActionsBuilderChangeSetModifier(private val r result.newMemberLabelAccess = AccessControl.AccessRequired.UNKNOWN } + override fun clearTerminateGroup() { + result.terminateGroup = false + } + private fun List.removeIndex(i: Int): List { val modifiedList = this.toMutableList() modifiedList.removeAt(i) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt index 3061d2bbe1..59ee20c86b 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt @@ -89,6 +89,7 @@ fun DecryptedGroupChange.getChangedFields(): Set { if (newRequestingMembers.isNotEmpty()) add(GroupChangeField.REQUESTING_MEMBERS) if (newTimer != null) add(GroupChangeField.TIMER) if (newTitle != null) add(GroupChangeField.TITLE) + if (terminateGroup) add(GroupChangeField.TERMINATE_GROUP) } } @@ -129,6 +130,7 @@ enum class GroupChangeField(val changeSilently: Boolean = false) { REQUESTING_MEMBER_APPROVALS, REQUESTING_MEMBER_REMOVALS, REQUESTING_MEMBERS, + TERMINATE_GROUP, TIMER, TITLE; diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java index 5607ce9668..5d5f75653f 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java @@ -341,6 +341,8 @@ public final class DecryptedGroupUtil { DecryptedGroupExtensions.setModifyMemberLabelActions(builder, change.modifyMemberLabels); + applyTerminateGroup(builder, change); + return builder.build(); } @@ -535,6 +537,12 @@ public final class DecryptedGroupUtil { } } + private static void applyTerminateGroup(DecryptedGroup.Builder builder, DecryptedGroupChange change) { + if (change.terminateGroup) { + builder.terminated(true); + } + } + private static void applyAddRequestingMembers(DecryptedGroup.Builder builder, List newRequestingMembers) { List requestingMembers = new ArrayList<>(builder.requestingMembers); requestingMembers.addAll(newRequestingMembers); diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt index f4a2f827e7..ae9e386e4f 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt @@ -109,6 +109,10 @@ internal class GroupChangeActionsBuilderChangeSetModifier(private val result: Gr result.modifyMemberLabelAccess = null } + override fun clearTerminateGroup() { + result.terminate_group = null + } + private fun List.removeIndex(i: Int): List { val modifiedList = this.toMutableList() modifiedList.removeAt(i) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java index 84a63e91ef..4eba002ef8 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java @@ -186,6 +186,10 @@ public final class GroupChangeReconstruct { }) .collect(Collectors.toList())); + if (!fromState.terminated && toState.terminated) { + builder.terminateGroup(true); + } + return builder.build(); } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java index 359b35b49e..bb9c6e23b1 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java @@ -52,7 +52,8 @@ public final class GroupChangeUtil { change.delete_members_banned.isEmpty() && // field 23 change.promote_members_pending_pni_aci_profile_key.isEmpty() && // field 24 change.modifyMemberLabels.isEmpty() && // field 26 - change.modifyMemberLabelAccess == null; // field 27 + change.modifyMemberLabelAccess == null && // field 27 + change.terminate_group == null; // field 28 } /** @@ -157,6 +158,7 @@ public final class GroupChangeUtil { resolveField24PromotePendingPniAciMembers (conflictingChange, changeSetModifier, fullMembersByUuid); resolveField26ModifyMemberLabels (conflictingChange, changeSetModifier, fullMembersByUuid); resolveField27ModifyMemberLabelAccess (groupState, conflictingChange, changeSetModifier); + resolveField28TerminateGroup (groupState, conflictingChange, changeSetModifier); } private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap fullMembersByUuid, HashMap pendingMembersByServiceId) { @@ -403,4 +405,13 @@ public final class GroupChangeUtil { result.clearModifyMemberLabelAccess(); } } + + private static void resolveField28TerminateGroup(@Nonnull DecryptedGroup groupState, + @Nonnull DecryptedGroupChange conflictingChange, + @Nonnull ChangeSetModifier result) + { + if (groupState.terminated && conflictingChange.terminateGroup) { + result.clearTerminateGroup(); + } + } } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index d6112c893f..86ba302c22 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -75,7 +75,7 @@ public final class GroupsV2Operations { public static final UUID UNKNOWN_UUID = UuidUtil.UNKNOWN_UUID; /** Highest change epoch this class knows now to decrypt */ - public static final int HIGHEST_KNOWN_EPOCH = 6; + public static final int HIGHEST_KNOWN_EPOCH = 7; private final ServerPublicParams serverPublicParams; private final ClientZkProfileOperations clientZkProfileOperations; @@ -350,6 +350,12 @@ public final class GroupsV2Operations { ); } + public GroupChange.Actions.Builder createTerminateGroup() { + return new GroupChange.Actions.Builder().terminate_group( + new GroupChange.Actions.TerminateGroupAction.Builder().build() + ); + } + public GroupChange.Actions.Builder createAnnouncementGroupChange(boolean isAnnouncementGroup) { return new GroupChange.Actions.Builder().modify_announcements_only( new GroupChange.Actions.ModifyAnnouncementsOnlyAction.Builder().announcements_only(isAnnouncementGroup).build() @@ -493,6 +499,7 @@ public final class GroupsV2Operations { .disappearingMessagesTimer(new DecryptedTimer.Builder().duration(decryptDisappearingMessagesTimer(group.disappearingMessagesTimer)).build()) .inviteLinkPassword(group.inviteLinkPassword) .bannedMembers(decryptedBannedMembers) + .terminated(group.terminated) .build(); } @@ -781,6 +788,11 @@ public final class GroupsV2Operations { builder.newMemberLabelAccess(actions.modifyMemberLabelAccess.memberLabelAccess); } + // Field 28 + if (actions.terminate_group != null) { + builder.terminateGroup(true); + } + if (editorServiceId instanceof ServiceId.PNI) { if (actions.addMembers.size() == 1 && builder.newMembers.size() == 1) { GroupChange.Actions.AddMemberAction addMemberAction = actions.addMembers.get(0); diff --git a/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto b/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto index 66993d33c8..bee930b9b2 100644 --- a/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto +++ b/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto @@ -79,6 +79,7 @@ message DecryptedGroup { string description = 11; EnabledState isAnnouncementGroup = 12; repeated DecryptedBannedMember bannedMembers = 13; + bool terminated = 14; bool isPlaceholderGroup = 64; } @@ -112,6 +113,7 @@ message DecryptedGroupChange { repeated DecryptedMember promotePendingPniAciMembers = 24; repeated DecryptedModifyMemberLabel modifyMemberLabels = 26; AccessControl.AccessRequired newMemberLabelAccess = 27; + bool terminateGroup = 28; } message DecryptedString { diff --git a/lib/libsignal-service/src/main/protowire/Groups.proto b/lib/libsignal-service/src/main/protowire/Groups.proto index e15ace6875..598705e390 100644 --- a/lib/libsignal-service/src/main/protowire/Groups.proto +++ b/lib/libsignal-service/src/main/protowire/Groups.proto @@ -88,7 +88,8 @@ message Group { bytes inviteLinkPassword = 10; bool announcements_only = 12; repeated MemberBanned members_banned = 13; - // next: 14 + bool terminated = 14; + // next: 15 } message GroupAttributeBlob { @@ -238,6 +239,8 @@ message GroupChange { bool announcements_only = 1; } + message TerminateGroupAction {} + bytes sourceUserId = 1; // clients should not provide this value; the server will provide it in the response buffer to ensure the signature is binding to a particular group // if clients set it during a request the server will respond with 400. @@ -268,7 +271,8 @@ message GroupChange { repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5 repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6; ModifyMemberLabelAccessControlAction modifyMemberLabelAccess = 27; // change epoch = 6 - // next: 28 + TerminateGroupAction terminate_group = 28; // change epoch = 7 + // next: 29 } bytes actions = 1; diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java index 58a2352018..ef265dc586 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java @@ -51,7 +51,7 @@ public final class DecryptedGroupUtil_apply_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } @Test @@ -1084,4 +1084,23 @@ public final class DecryptedGroupUtil_apply_Test { assertEquals(expectedResult, DecryptedGroupUtil.apply(group, groupChange)); } + + @Test + public void apply_terminate_group() throws NotAbleToApplyGroupV2ChangeException { + DecryptedGroup group = new DecryptedGroup.Builder() + .revision(10) + .build(); + + DecryptedGroupChange groupChange = new DecryptedGroupChange.Builder() + .revision(11) + .terminateGroup(true) + .build(); + + DecryptedGroup expectedResult = new DecryptedGroup.Builder() + .revision(11) + .terminated(true) + .build(); + + assertEquals(expectedResult, DecryptedGroupUtil.apply(group, groupChange)); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java index 8774309b0d..973b174378 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java @@ -41,7 +41,7 @@ public final class DecryptedGroupUtil_empty_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupChangeField and getChangedFields() need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } @Test @@ -295,6 +295,16 @@ public final class DecryptedGroupUtil_empty_Test { assertFalse(DecryptedGroupExtensions.isSilent(change)); } + @Test + public void not_empty_with_terminate_group_field_28() { + DecryptedGroupChange change = new DecryptedGroupChange.Builder() + .terminateGroup(true) + .build(); + + assertFalse(DecryptedGroupExtensions.getChangedFields(change).isEmpty()); + assertFalse(DecryptedGroupExtensions.isSilent(change)); + } + @Test public void silent_with_profile_keys_and_banned_members() { DecryptedGroupChange change = new DecryptedGroupChange.Builder() diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java index 7c72889603..f28d8486ef 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java @@ -45,7 +45,7 @@ public final class GroupChangeReconstructTest { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS); assertEquals("GroupChangeReconstruct and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @Test @@ -487,4 +487,22 @@ public final class GroupChangeReconstructTest { .build(), decryptedGroupChange); } + + @Test + public void terminate_group() { + DecryptedGroup from = new DecryptedGroup.Builder() + .build(); + + DecryptedGroup to = new DecryptedGroup.Builder() + .terminated(true) + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals( + new DecryptedGroupChange.Builder() + .terminateGroup(true) + .build(), + decryptedGroupChange); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java index 51d1fae5e8..91ec41900c 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java @@ -22,7 +22,7 @@ public final class GroupChangeUtil_changeIsEmpty_Test { int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class); assertEquals("GroupChangeUtil and its tests need updating to account for new fields on " + GroupChange.Actions.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } @Test @@ -245,4 +245,13 @@ public final class GroupChangeUtil_changeIsEmpty_Test { assertFalse(GroupChangeUtil.changeIsEmpty(actions)); } + + @Test + public void not_empty_with_terminate_group_field_28() { + GroupChange.Actions actions = new GroupChange.Actions.Builder() + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java index da6b7cb414..2458cac7d1 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java @@ -53,7 +53,7 @@ public final class GroupChangeUtil_resolveConflict_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } /** @@ -66,7 +66,7 @@ public final class GroupChangeUtil_resolveConflict_Test { int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + GroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } /** @@ -79,7 +79,7 @@ public final class GroupChangeUtil_resolveConflict_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @@ -993,4 +993,53 @@ public final class GroupChangeUtil_resolveConflict_Test { GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); } + + @Test + public void field_28__terminate_group_preserved_when_group_not_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(false) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + GroupChange.Actions conflictingActions = new GroupChange.Actions.Builder() + .version(6) + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + GroupChange.Actions expectedResolvedActions = new GroupChange.Actions.Builder() + .version(6) + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + assertEquals(expectedResolvedActions, GroupChangeUtil.resolveConflict(groupState, conflictingChange, conflictingActions).build()); + } + + @Test + public void field_28__terminate_group_removed_when_group_already_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(true) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + GroupChange.Actions conflictingActions = new GroupChange.Actions.Builder() + .version(6) + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + GroupChange.Actions expectedResolvedActions = new GroupChange.Actions.Builder() + .version(6) + .build(); + + assertEquals(expectedResolvedActions, GroupChangeUtil.resolveConflict(groupState, conflictingChange, conflictingActions).build()); + } } \ No newline at end of file diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java index 3d56c33425..a9d8a70b56 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java @@ -46,7 +46,7 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } /** @@ -59,7 +59,7 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @@ -785,4 +785,43 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test { DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build(); assertTrue(DecryptedGroupExtensions.getChangedFields(resolvedChanges).isEmpty()); } + + @Test + public void field_28__terminate_group_preserved_when_group_not_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(false) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + DecryptedGroupChange expectedResolvedChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + assertEquals(expectedResolvedChange, GroupChangeUtil.resolveConflict(groupState, conflictingChange).build()); + } + + @Test + public void field_28__terminate_group_removed_when_group_already_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(true) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + DecryptedGroupChange expectedResolvedChange = new DecryptedGroupChange.Builder() + .revision(6) + .build(); + + assertEquals(expectedResolvedChange, GroupChangeUtil.resolveConflict(groupState, conflictingChange).build()); + } } \ No newline at end of file diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java index f60c256262..b6f5dd7cf4 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java @@ -73,7 +73,7 @@ public final class GroupsV2Operations_decrypt_change_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupV2Operations#decryptChange and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, + 28, maxFieldFound); } @@ -544,6 +544,16 @@ public final class GroupsV2Operations_decrypt_change_Test { assertDecryption(encryptedChange, expectedDecryptedChange); } + @Test + public void can_pass_through_terminate_group_field_28() { + GroupChange.Actions.Builder encryptedChange = groupOperations.createTerminateGroup(); + + DecryptedGroupChange.Builder expectedDecryptedChange = new DecryptedGroupChange.Builder() + .terminateGroup(true); + + assertDecryption(encryptedChange, expectedDecryptedChange); + } + private static ProfileKey newProfileKey() { try { return new ProfileKey(Util.getSecretBytes(32)); diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java index 9ad701cb3f..55cbaddade 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java @@ -58,7 +58,7 @@ public final class GroupsV2Operations_decrypt_group_Test { int maxFieldFound = getMaxDeclaredFieldNumber(Group.class); assertEquals("GroupOperations and its tests need updating to account for new fields on " + Group.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @Test @@ -310,6 +310,17 @@ public final class GroupsV2Operations_decrypt_group_Test { assertEquals(new DecryptedBannedMember.Builder().serviceIdBytes(member1.toByteString()).build(), decryptedGroup.bannedMembers.get(0)); } + @Test + public void pass_through_terminated_field_14() throws VerificationFailedException, InvalidGroupStateException { + Group group = new Group.Builder() + .terminated(true) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(true, decryptedGroup.terminated); + } + private ByteString encryptProfileKey(ACI aci, ProfileKey profileKey) { return ByteString.of(new ClientZkGroupCipher(groupSecretParams).encryptProfileKey(profileKey, aci.getLibSignalAci()).serialize()); }