From 0632af73916a439b429ef8ba5c8c414810db90ca Mon Sep 17 00:00:00 2001 From: Max Radermacher Date: Mon, 13 Apr 2026 14:16:48 -0500 Subject: [PATCH] =?UTF-8?q?Move=20=E2=80=9Ccan=20local=20user=20leave?= =?UTF-8?q?=E2=80=9D=20check=20to=20membership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Signal.xcodeproj/project.pbxproj | 4 +++ .../ConversationSettingsViewController.swift | 5 +-- .../LeaveGroupCoordinator.swift | 7 +--- SignalServiceKit/Groups/GroupManager.swift | 28 ---------------- SignalServiceKit/Groups/GroupMembership.swift | 22 +++++++++++++ .../tests/Groups/GroupMembershipTest.swift | 33 +++++++++++++++++++ 6 files changed, 61 insertions(+), 38 deletions(-) create mode 100644 SignalServiceKit/tests/Groups/GroupMembershipTest.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 92df2bd1c8..8024770a3d 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -777,6 +777,7 @@ 509085BC2C498D3600409B85 /* LinkPreviewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509085BB2C498D3500409B85 /* LinkPreviewFetcher.swift */; }; 509085BE2C49C29400409B85 /* PaddingBucket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509085BD2C49C29400409B85 /* PaddingBucket.swift */; }; 509085C02C49C2A500409B85 /* PaddingBucketTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509085BF2C49C2A500409B85 /* PaddingBucketTest.swift */; }; + 5090B1A12F8D7239003F029D /* GroupMembershipTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5090B1A02F8D7239003F029D /* GroupMembershipTest.swift */; }; 50925DEA2DA86E3A00DAB484 /* GroupMessageProcessorJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50925DE92DA86E3A00DAB484 /* GroupMessageProcessorJob.swift */; }; 50925DEC2DA87AEF00DAB484 /* GroupMessageProcessorJobTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50925DEB2DA87AEF00DAB484 /* GroupMessageProcessorJobTest.swift */; }; 5094D5052EE3A6780041F402 /* AttachmentLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5094D5042EE3A6780041F402 /* AttachmentLimits.swift */; }; @@ -5052,6 +5053,7 @@ 509085BB2C498D3500409B85 /* LinkPreviewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewFetcher.swift; sourceTree = ""; }; 509085BD2C49C29400409B85 /* PaddingBucket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingBucket.swift; sourceTree = ""; }; 509085BF2C49C2A500409B85 /* PaddingBucketTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingBucketTest.swift; sourceTree = ""; }; + 5090B1A02F8D7239003F029D /* GroupMembershipTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMembershipTest.swift; sourceTree = ""; }; 50925DE92DA86E3A00DAB484 /* GroupMessageProcessorJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMessageProcessorJob.swift; sourceTree = ""; }; 50925DEB2DA87AEF00DAB484 /* GroupMessageProcessorJobTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMessageProcessorJobTest.swift; sourceTree = ""; }; 5094D5042EE3A6780041F402 /* AttachmentLimits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentLimits.swift; sourceTree = ""; }; @@ -14334,6 +14336,7 @@ F94261E2289B1B5400460798 /* Groups */ = { isa = PBXGroup; children = ( + 5090B1A02F8D7239003F029D /* GroupMembershipTest.swift */, F94261E3289B1B5400460798 /* GroupModelsTest.swift */, ); name = Groups; @@ -20202,6 +20205,7 @@ F97217FE28DCBC5100113D9F /* GRDBSchemaMigratorTest.swift in Sources */, D979CC5E2AD618EA006AAC49 /* GroupCallRecordManagerTest.swift in Sources */, D91F0B4F2B193A7A0086DB30 /* GroupCallRecordRingUpdateDelegateTest.swift in Sources */, + 5090B1A12F8D7239003F029D /* GroupMembershipTest.swift in Sources */, 5075C21729CA1EE700A260D2 /* GroupMemberUpdaterTest.swift in Sources */, 50925DEC2DA87AEF00DAB484 /* GroupMessageProcessorJobTest.swift in Sources */, F9426251289B1B5500460798 /* GroupModelsTest.swift in Sources */, diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index a6153f9b32..a681a23f37 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -687,10 +687,7 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti let groupThread = thread as? TSGroupThread, let groupModel = groupThread.groupModel as? TSGroupModelV2, let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci, - GroupManager.canLocalUserLeaveGroupWithoutChoosingNewAdmin( - localAci: localAci, - groupMembership: groupModel.groupMembership, - ) + groupModel.groupMembership.canLocalUserLeaveGroupWithoutChoosingNewAdmin(localAci: localAci) { LeaveGroupCoordinator( groupThread: groupThread, diff --git a/Signal/src/ViewControllers/ThreadSettings/LeaveGroupCoordinator.swift b/Signal/src/ViewControllers/ThreadSettings/LeaveGroupCoordinator.swift index 256840e591..719955e196 100644 --- a/Signal/src/ViewControllers/ThreadSettings/LeaveGroupCoordinator.swift +++ b/Signal/src/ViewControllers/ThreadSettings/LeaveGroupCoordinator.swift @@ -30,12 +30,7 @@ class LeaveGroupCoordinator: ReplaceAdminViewControllerDelegate { // Retain self for the lifetime of rootViewController. ObjectRetainer.retainObject(self, forLifetimeOf: rootViewController) - if - GroupManager.canLocalUserLeaveGroupWithoutChoosingNewAdmin( - localAci: localAci, - groupMembership: groupModel.groupMembership, - ) - { + if groupModel.groupMembership.canLocalUserLeaveGroupWithoutChoosingNewAdmin(localAci: localAci) { showLeaveGroupConfirmAlert( fromViewController: rootViewController, replacementAdminAci: nil, diff --git a/SignalServiceKit/Groups/GroupManager.swift b/SignalServiceKit/Groups/GroupManager.swift index ffdff7f2a8..99ff1bb3bd 100644 --- a/SignalServiceKit/Groups/GroupManager.swift +++ b/SignalServiceKit/Groups/GroupManager.swift @@ -70,34 +70,6 @@ public class GroupManager: NSObject { return isV1GroupId(groupId) || isV2GroupId(groupId) } - // MARK: - - - public static func canLocalUserLeaveGroupWithoutChoosingNewAdmin( - localAci: Aci, - groupMembership: GroupMembership, - ) -> Bool { - let fullMembers = Set(groupMembership.fullMembers.compactMap { $0.serviceId as? Aci }) - let fullMemberAdmins = Set(groupMembership.fullMemberAdministrators.compactMap { $0.serviceId as? Aci }) - return canLocalUserLeaveGroupWithoutChoosingNewAdmin( - localAci: localAci, - fullMembers: fullMembers, - admins: fullMemberAdmins, - ) - } - - public static func canLocalUserLeaveGroupWithoutChoosingNewAdmin( - localAci: Aci, - fullMembers: Set, - admins: Set, - ) -> Bool { - // If the current user is the only admin and they're not the only member of - // the group, then they must select a new admin. - if Set([localAci]) == admins, Set([localAci]) != fullMembers { - return false - } - return true - } - // MARK: - Group Models /// Confirms that a given address supports V2 groups. diff --git a/SignalServiceKit/Groups/GroupMembership.swift b/SignalServiceKit/Groups/GroupMembership.swift index cad1630d9c..dac3022f60 100644 --- a/SignalServiceKit/Groups/GroupMembership.swift +++ b/SignalServiceKit/Groups/GroupMembership.swift @@ -585,6 +585,28 @@ public class GroupMembership: NSObject, NSSecureCoding { // MARK: - + public func canLocalUserLeaveGroupWithoutChoosingNewAdmin(localAci: Aci) -> Bool { + let fullMembers = Set(self.fullMembers.compactMap { $0.serviceId as? Aci }) + let fullMemberAdmins = Set(self.fullMemberAdministrators.compactMap { $0.serviceId as? Aci }) + return Self.canLocalUserLeaveGroupWithoutChoosingNewAdmin( + localAci: localAci, + fullMembers: fullMembers, + admins: fullMemberAdmins, + ) + } + + static func canLocalUserLeaveGroupWithoutChoosingNewAdmin( + localAci: Aci, + fullMembers: Set, + admins: Set, + ) -> Bool { + // If there's already another admin or we're the only member, we can leave + // without selecting a new admin. + return Set([localAci]) != admins || Set([localAci]) == fullMembers + } + + // MARK: - + /// Is this user's profile key exposed to the group? public func hasProfileKeyInGroup(serviceId: ServiceId) -> Bool { guard let memberState = memberStates[SignalServiceAddress(serviceId)] else { diff --git a/SignalServiceKit/tests/Groups/GroupMembershipTest.swift b/SignalServiceKit/tests/Groups/GroupMembershipTest.swift new file mode 100644 index 0000000000..6c6764968b --- /dev/null +++ b/SignalServiceKit/tests/Groups/GroupMembershipTest.swift @@ -0,0 +1,33 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import LibSignalClient +import Testing + +@testable import SignalServiceKit + +struct GroupMembershipTest { + private static let localAci = LocalIdentifiers.forUnitTests.aci + private static let otherAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000000") + @Test(arguments: [ + (true, [], []), + (true, [Self.localAci], [Self.localAci]), + (false, [Self.localAci], [Self.localAci, Self.otherAci]), + (true, [Self.localAci, Self.otherAci], [Self.localAci, Self.otherAci]), + (true, [], [Self.localAci]), + (true, [], [Self.localAci, Self.otherAci]), + (true, [Self.otherAci], [Self.localAci, Self.otherAci]), + ]) + func testCanLocalUserLeaveGroup(testCase: (canLeave: Bool, admins: [Aci], members: [Aci])) { + let localAci = Self.localAci + let canLeave = GroupMembership.canLocalUserLeaveGroupWithoutChoosingNewAdmin( + localAci: localAci, + fullMembers: Set(testCase.members), + admins: Set(testCase.admins), + ) + #expect(canLeave == testCase.canLeave) + } +}