Add "replace admin" flow.

This commit is contained in:
Matthew Chen 2020-04-07 10:04:52 -03:00
parent b5c069ed95
commit ef53148b33
9 changed files with 242 additions and 26 deletions

View File

@ -194,6 +194,7 @@
347B83F92436820C0019A52C /* GroupViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83F82436820C0019A52C /* GroupViewHelper.swift */; };
347B83FB243779270019A52C /* OWSFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83FA243779270019A52C /* OWSFormat.swift */; };
347B83FD24378DDF0019A52C /* PendingGroupMembersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83FC24378DDE0019A52C /* PendingGroupMembersViewController.swift */; };
348433DF243CA94600C7F64A /* ReplaceAdminViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348433DE243CA94500C7F64A /* ReplaceAdminViewController.swift */; };
34843B2421432293004DED45 /* SignalBaseTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 34843B2221432292004DED45 /* SignalBaseTest.m */; };
34843B26214327C9004DED45 /* OWSOrphanDataCleanerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 34843B25214327C9004DED45 /* OWSOrphanDataCleanerTest.m */; };
34843B2C214FE296004DED45 /* MockEnvironment.m in Sources */ = {isa = PBXBuildFile; fileRef = 34843B2A214FE295004DED45 /* MockEnvironment.m */; };
@ -1072,6 +1073,7 @@
347B83F82436820C0019A52C /* GroupViewHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupViewHelper.swift; sourceTree = "<group>"; };
347B83FA243779270019A52C /* OWSFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSFormat.swift; sourceTree = "<group>"; };
347B83FC24378DDE0019A52C /* PendingGroupMembersViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PendingGroupMembersViewController.swift; sourceTree = "<group>"; };
348433DE243CA94500C7F64A /* ReplaceAdminViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplaceAdminViewController.swift; sourceTree = "<group>"; };
34843B2221432292004DED45 /* SignalBaseTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalBaseTest.m; sourceTree = "<group>"; };
34843B2321432293004DED45 /* SignalBaseTest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalBaseTest.h; sourceTree = "<group>"; };
34843B25214327C9004DED45 /* OWSOrphanDataCleanerTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOrphanDataCleanerTest.m; sourceTree = "<group>"; };
@ -1979,6 +1981,7 @@
3425648723E214BC0016905B /* OWSConversationSettingsViewController.swift */,
340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */,
347B83FC24378DDE0019A52C /* PendingGroupMembersViewController.swift */,
348433DE243CA94500C7F64A /* ReplaceAdminViewController.swift */,
340FC89E204DAC8D007AEB0F /* ShowGroupMembersViewController.h */,
340FC8A6204DAC8D007AEB0F /* ShowGroupMembersViewController.m */,
340FC8A3204DAC8D007AEB0F /* UpdateGroupViewController.h */,
@ -4544,6 +4547,7 @@
340FC8AF204DAC8D007AEB0F /* OWSLinkDeviceViewController.m in Sources */,
8806F21B23E90B0B00692638 /* MessageRequests.swift in Sources */,
3434AE1E22AEF5A4002EE04E /* CircularProgressView.swift in Sources */,
348433DF243CA94600C7F64A /* ReplaceAdminViewController.swift in Sources */,
34E3EF0D1EFC235B007F6822 /* DebugUIDiskUsage.m in Sources */,
887EEC1F23F0B20600F8C26D /* PinReminderMegaphone.swift in Sources */,
454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */,

View File

@ -89,12 +89,6 @@ class ConversationSettingsViewController: OWSTableViewController {
groupViewHelper.delegate = self
}
@available(*, unavailable, message:"use other constructor instead.")
@objc
public required init(coder aDecoder: NSCoder) {
notImplemented()
}
private func observeNotifications() {
NotificationCenter.default.addObserver(self,
selector: #selector(identityStateDidChange(notification:)),
@ -523,11 +517,22 @@ class ConversationSettingsViewController: OWSTableViewController {
}
func didTapLeaveGroup() {
if isLastAdminInV2Group {
showReplaceAdminAlert()
} else {
showLeaveGroupConfirmAlert()
}
}
let alert = ActionSheetController(title: NSLocalizedString("CONFIRM_LEAVE_GROUP_TITLE", comment: "Alert title"),
message: NSLocalizedString("CONFIRM_LEAVE_GROUP_DESCRIPTION", comment: "Alert body"))
func showLeaveGroupConfirmAlert() {
let leaveAction = ActionSheetAction(title: NSLocalizedString("LEAVE_BUTTON_TITLE", comment: "Confirmation button within contextual alert"),
let alert = ActionSheetController(title: NSLocalizedString("CONFIRM_LEAVE_GROUP_TITLE",
comment: "Alert title"),
message: NSLocalizedString("CONFIRM_LEAVE_GROUP_DESCRIPTION",
comment: "Alert body"))
let leaveAction = ActionSheetAction(title: NSLocalizedString("LEAVE_BUTTON_TITLE",
comment: "Confirmation button within contextual alert"),
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "leave_group_confirm"),
style: .destructive) { _ in
self.leaveGroup()
@ -538,6 +543,74 @@ class ConversationSettingsViewController: OWSTableViewController {
presentActionSheet(alert)
}
func showReplaceAdminAlert() {
let candidates = self.replacementAdminCandidates
guard !candidates.isEmpty else {
// TODO: We could offer a "delete group locally" option here.
OWSActionSheets.showErrorAlert(message: NSLocalizedString("GROUPS_CANT_REPLACE_ADMIN_ALERT_MESSAGE",
comment: "Message for the 'can't replace group admin' alert."))
return
}
let alert = ActionSheetController(title: NSLocalizedString("GROUPS_REPLACE_ADMIN_ALERT_TITLE",
comment: "Title for the 'replace group admin' alert."),
message: NSLocalizedString("GROUPS_REPLACE_ADMIN_ALERT_MESSAGE",
comment: "Message for the 'replace group admin' alert."))
alert.addAction(ActionSheetAction(title: NSLocalizedString("GROUPS_REPLACE_ADMIN_BUTTON",
comment: "Label for the 'replace group admin' button."),
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "replace_admin_alert"),
style: .destructive) { _ in
self.showReplaceAdminView(candidates: candidates)
})
alert.addAction(OWSActionSheets.cancelAction)
presentActionSheet(alert)
}
func showReplaceAdminView(candidates: Set<SignalServiceAddress>) {
assert(!candidates.isEmpty)
let replaceAdminViewController = ReplaceAdminViewController(candidates: candidates,
replaceAdminViewControllerDelegate: self)
navigationController?.pushViewController(replaceAdminViewController, animated: true)
}
private var isLastAdminInV2Group: Bool {
guard let groupThread = thread as? TSGroupThread else {
owsFailDebug("Invalid thread.")
return false
}
guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else {
return false
}
guard let localAddress = tsAccountManager.localAddress else {
owsFailDebug("missing local address")
return false
}
let groupMembership = groupModelV2.groupMembership
guard groupMembership.isNonPendingMember(localAddress),
groupMembership.isAdministrator(localAddress) else {
return false
}
return true
}
private var replacementAdminCandidates: Set<SignalServiceAddress> {
guard let groupThread = thread as? TSGroupThread else {
owsFailDebug("Invalid thread.")
return []
}
guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else {
return []
}
guard let localAddress = tsAccountManager.localAddress else {
owsFailDebug("missing local address")
return []
}
var candidates = groupModelV2.groupMembership.nonPendingAdministrators
candidates.remove(localAddress)
return candidates
}
private func leaveGroup() {
guard let groupThread = thread as? TSGroupThread else {
owsFailDebug("Invalid thread.")
@ -933,3 +1006,11 @@ extension ConversationSettingsViewController: GroupViewHelperDelegate {
return self
}
}
// MARK: -
extension ConversationSettingsViewController: ReplaceAdminViewControllerDelegate {
func replaceAdmin(address: SignalServiceAddress) {
// TODO:
}
}

View File

@ -0,0 +1,89 @@
//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
protocol ReplaceAdminViewControllerDelegate: class {
func replaceAdmin(address: SignalServiceAddress)
}
// MARK: -
class ReplaceAdminViewController: OWSTableViewController {
// MARK: - Dependencies
private var contactsManager: OWSContactsManager {
return Environment.shared.contactsManager
}
private var databaseStorage: SDSDatabaseStorage {
return SDSDatabaseStorage.shared
}
// MARK: -
weak var replaceAdminViewControllerDelegate: ReplaceAdminViewControllerDelegate?
private let candidates: Set<SignalServiceAddress>
required init(candidates: Set<SignalServiceAddress>,
replaceAdminViewControllerDelegate: ReplaceAdminViewControllerDelegate) {
assert(!candidates.isEmpty)
self.candidates = candidates
self.replaceAdminViewControllerDelegate = replaceAdminViewControllerDelegate
super.init()
}
// MARK: - View Lifecycle
@objc
public override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("REPLACE_ADMIN_VIEW_TITLE",
comment: "The title for the 'replace group admin' view.")
view.backgroundColor = Theme.backgroundColor
tableView.backgroundColor = Theme.backgroundColor
tableView.separatorColor = .clear
updateTableContents()
}
private func updateTableContents() {
let contents = OWSTableContents()
let section = OWSTableSection()
let sortedCandidates = databaseStorage.uiRead { transaction in
self.contactsManager.sortSignalServiceAddresses(Array(self.candidates), transaction: transaction)
}
for address in sortedCandidates {
section.add(OWSTableItem(customCellBlock: {
let cell = ContactTableViewCell()
let imageView = UIImageView()
imageView.setTemplateImageName("empty-circle-outline-24", tintColor: .ows_gray25)
cell.ows_setAccessoryView(imageView)
cell.configure(withRecipientAddress: address)
return cell
},
customRowHeight: UITableView.automaticDimension,
actionBlock: { [weak self] in
self?.replaceAdminViewControllerDelegate?.replaceAdmin(address: address)
}
))
}
contents.addSection(section)
self.contents = contents
}
}

View File

@ -154,7 +154,7 @@ extension SignalCall: CallManagerCallReference { }
return AppEnvironment.shared.notificationPresenter
}
var databaseStorage: SDSDatabaseStorage {
private var databaseStorage: SDSDatabaseStorage {
return SDSDatabaseStorage.shared
}

View File

@ -1562,6 +1562,9 @@
/* Message indicating that the local user left the group. */
"GROUP_YOU_LEFT" = "You have left the group.";
/* Message for the 'can't replace group admin' alert. */
"GROUPS_CANT_REPLACE_ADMIN_ALERT_MESSAGE" = "Choose a new admin for this group before you leave.";
/* Error indicating that an error occurred while accepting an invite. */
"GROUPS_INVITE_ACCEPT_INVITE_FAILED" = "Could not accept invite.";
@ -1574,6 +1577,15 @@
/* Label for 'block inviter' button in group invite view. Embeds {{name of user who invited you}}. */
"GROUPS_INVITE_BLOCK_INVITER_FORMAT" = "Block %@";
/* Message for the 'replace group admin' alert. */
"GROUPS_REPLACE_ADMIN_ALERT_MESSAGE" = "Before you leave, choose a new admin for this group.";
/* Title for the 'replace group admin' alert. */
"GROUPS_REPLACE_ADMIN_ALERT_TITLE" = "Choose New Admin";
/* Label for the 'replace group admin' button. */
"GROUPS_REPLACE_ADMIN_BUTTON" = "Choose new admin";
/* Label for 'archived conversations' button. */
"HOME_VIEW_ARCHIVED_CONVERSATIONS" = "Archived Conversations";
@ -2912,6 +2924,9 @@
/* Alert title after wrong guess for 'two-factor auth pin' reminder activity */
"REMINDER_2FA_WRONG_PIN_ALERT_TITLE" = "That is not the correct PIN.";
/* The title for the 'replace group admin' view. */
"REPLACE_ADMIN_VIEW_TITLE" = "Choose New Admin";
/* No comment provided by engineer. */
"REREGISTER_FOR_PUSH" = "Re-register for push notifications";

View File

@ -361,6 +361,8 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
Logger.verbose("Version: \(oldVersion) -> \(newVersion)")
actionsBuilder.setVersion(newVersion)
var nonPendingAdministratorCount: Int = currentGroupModel.groupMembership.nonPendingAdministrators.count
var didChange = false
if let title = self.title {
@ -400,7 +402,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
continue
}
guard let profileKeyCredential = profileKeyCredentialMap[uuid] else {
throw OWSAssertionError("Missing profile key credential]: \(uuid)")
throw OWSAssertionError("Missing profile key credential: \(uuid)")
}
let actionBuilder = GroupsProtoGroupChangeActionsAddMemberAction.builder()
actionBuilder.setAdded(try GroupsV2Protos.buildMemberProto(profileKeyCredential: profileKeyCredential,
@ -408,6 +410,10 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
groupV2Params: groupV2Params))
actionsBuilder.addAddMembers(try actionBuilder.build())
didChange = true
if role == .administrator {
nonPendingAdministratorCount += 1
}
}
for (uuid, role) in self.pendingMembersToAdd {
@ -433,6 +439,10 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
actionBuilder.setDeletedUserID(userId)
actionsBuilder.addDeleteMembers(try actionBuilder.build())
didChange = true
if currentGroupMembership.isAdministrator(SignalServiceAddress(uuid: uuid)) {
nonPendingAdministratorCount -= 1
}
} else if currentGroupMembership.isPendingMember(uuid) {
let actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder()
let userId = try groupV2Params.userId(forUuid: uuid)
@ -447,12 +457,13 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
}
}
for (uuid, role) in self.membersToChangeRole {
for (uuid, newRole) in self.membersToChangeRole {
guard currentGroupMembership.isNonPendingMember(uuid) else {
// User is no longer a member.
throw GroupsV2Error.conflictingChange
}
guard currentGroupMembership.role(for: SignalServiceAddress(uuid: uuid)) != role else {
let currentRole = currentGroupMembership.role(for: SignalServiceAddress(uuid: uuid))
guard currentRole != newRole else {
// Another user has already modifed the role of this member.
// We don't treat that as a conflict.
continue
@ -460,9 +471,15 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
let actionBuilder = GroupsProtoGroupChangeActionsModifyMemberRoleAction.builder()
let userId = try groupV2Params.userId(forUuid: uuid)
actionBuilder.setUserID(userId)
actionBuilder.setRole(role.asProtoRole)
actionBuilder.setRole(newRole.asProtoRole)
actionsBuilder.addModifyMemberRoles(try actionBuilder.build())
didChange = true
if currentRole == .administrator {
nonPendingAdministratorCount -= 1
} else if newRole == .administrator {
nonPendingAdministratorCount += 1
}
}
let currentAccess = currentGroupModel.access
@ -493,7 +510,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
throw GroupsV2Error.redundantChange
}
guard let profileKeyCredential = profileKeyCredentialMap[localUuid] else {
throw OWSAssertionError("Missing profile key credential]: \(localUuid)")
throw OWSAssertionError("Missing profile key credential: \(localUuid)")
}
let actionBuilder = GroupsProtoGroupChangeActionsPromotePendingMemberAction.builder()
actionBuilder.setPresentation(try GroupsV2Protos.presentationData(profileKeyCredential: profileKeyCredential,
@ -503,6 +520,15 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
}
if self.shouldLeaveGroupDeclineInvite {
let isLastAdminInV2Group = (currentGroupMembership.isNonPendingMember(localAddress) &&
currentGroupMembership.isAdministrator(localAddress) &&
nonPendingAdministratorCount == 1)
guard !isLastAdminInV2Group else {
// This could happen if the last two admins leave at the same time
// and race.
throw GroupsV2Error.lastAdminCantLeaveGroup
}
// Check that we are still invited or in group.
if currentGroupMembership.pendingMembers.contains(localAddress) {
// Decline invite
@ -544,7 +570,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet {
if shouldUpdateLocalProfileKey {
guard let profileKeyCredential = profileKeyCredentialMap[localUuid] else {
throw OWSAssertionError("Missing profile key credential]: \(localUuid)")
throw OWSAssertionError("Missing profile key credential: \(localUuid)")
}
let actionBuilder = GroupsProtoGroupChangeActionsModifyMemberProfileKeyAction.builder()
actionBuilder.setPresentation(try GroupsV2Protos.presentationData(profileKeyCredential: profileKeyCredential,

View File

@ -107,8 +107,8 @@ public class GroupsV2Test: NSObject {
throw OWSAssertionError("Unexpected members: \(groupMembership.nonPendingMembers).")
}
let expectedAdministrators = expectedMembers
guard groupMembership.administrators == expectedAdministrators else {
throw OWSAssertionError("Unexpected administrators: \(groupMembership.administrators).")
guard groupMembership.nonPendingAdministrators == expectedAdministrators else {
throw OWSAssertionError("Unexpected administrators: \(groupMembership.nonPendingAdministrators).")
}
guard groupV2Snapshot.accessControlForMembers == .member else {
throw OWSAssertionError("Unexpected accessControlForMembers: \(groupV2Snapshot.accessControlForMembers).")
@ -120,7 +120,7 @@ public class GroupsV2Test: NSObject {
}.then(on: .global()) { (groupId: Data) throws -> Promise<TSGroupThread> in
let (groupThread, dmConfiguration) = try self.fetchGroupThread(groupId: groupId)
let oldGroupModel = groupThread.groupModel as! TSGroupModelV2
guard oldGroupModel.groupMembership.administrators == localAddressSet else {
guard oldGroupModel.groupMembership.nonPendingAdministrators == localAddressSet else {
throw OWSAssertionError("Unexpected groupMembership.")
}
guard oldGroupModel.groupMembership.nonAdminMembers.isEmpty else {
@ -196,8 +196,8 @@ public class GroupsV2Test: NSObject {
throw OWSAssertionError("Unexpected members: \(groupMembership.nonPendingMembers).")
}
let expectedAdministrators = localAddressSet
guard groupMembership.administrators == expectedAdministrators else {
throw OWSAssertionError("Unexpected administrators: \(groupMembership.administrators).")
guard groupMembership.nonPendingAdministrators == expectedAdministrators else {
throw OWSAssertionError("Unexpected administrators: \(groupMembership.nonPendingAdministrators).")
}
guard groupV2Snapshot.accessControlForMembers == .member else {
throw OWSAssertionError("Unexpected accessControlForMembers: \(groupV2Snapshot.accessControlForMembers).")
@ -211,7 +211,7 @@ public class GroupsV2Test: NSObject {
guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid group model.")
}
guard oldGroupModel.groupMembership.administrators == localAddressSet else {
guard oldGroupModel.groupMembership.nonPendingAdministrators == localAddressSet else {
throw OWSAssertionError("Unexpected groupMembership.")
}
guard oldGroupModel.groupMembership.nonAdminMembers == otherAddresses else {
@ -285,8 +285,8 @@ public class GroupsV2Test: NSObject {
throw OWSAssertionError("Unexpected members: \(groupMembership.nonPendingMembers).")
}
let expectedAdministrators = localAddressSet
guard groupMembership.administrators == expectedAdministrators else {
throw OWSAssertionError("Unexpected administrators: \(groupMembership.administrators).")
guard groupMembership.nonPendingAdministrators == expectedAdministrators else {
throw OWSAssertionError("Unexpected administrators: \(groupMembership.nonPendingAdministrators).")
}
guard groupV2Snapshot.accessControlForMembers == .member else {
throw OWSAssertionError("Unexpected accessControlForMembers: \(groupV2Snapshot.accessControlForMembers).")
@ -298,7 +298,7 @@ public class GroupsV2Test: NSObject {
}.map(on: .global()) { (groupId: Data) throws -> Data in
let (groupThread, dmConfiguration) = try self.fetchGroupThread(groupId: groupId)
let groupModel = groupThread.groupModel as! TSGroupModelV2
guard groupModel.groupMembership.administrators == localAddressSet else {
guard groupModel.groupMembership.nonPendingAdministrators == localAddressSet else {
throw OWSAssertionError("Unexpected groupMembership.")
}
guard groupModel.groupMembership.nonAdminMembers.isEmpty else {

View File

@ -86,7 +86,7 @@ public class GroupMembership: MTLModel {
return Set(memberStateMap.filter { !$0.value.isAdministrator && !$0.value.isPending }.keys)
}
@objc
public var administrators: Set<SignalServiceAddress> {
public var nonPendingAdministrators: Set<SignalServiceAddress> {
return Set(memberStateMap.filter { $0.value.isAdministrator && !$0.value.isPending }.keys)
}
@objc

View File

@ -17,6 +17,7 @@ public enum GroupsV2Error: Error {
case timeout
case localUserNotInGroup
case conflictingChange
case lastAdminCantLeaveGroup
}
@objc