admin delete receive & send

This commit is contained in:
kate-signal 2026-02-23 12:16:56 -05:00 committed by GitHub
parent 38bbe659fa
commit 4a0aec54b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1031 additions and 133 deletions

View File

@ -67,6 +67,8 @@
047A6DD02E00B5720048EDF4 /* BackupKeyReminderMegaphoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */; };
0480F0002E57C51A006CBB29 /* BackupsEnabledNotificationMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */; };
0484CECE2F44B7BE009AB2CB /* AdminDeleteRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */; };
0484CED02F44BD00009AB2CB /* AdminDeleteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */; };
0484CED22F44DD01009AB2CB /* OutgoingAdminDeleteMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484CED12F44DCFF009AB2CB /* OutgoingAdminDeleteMessage.swift */; };
048D58362ED4FABB00F26237 /* PinDisappearingMessageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048D58352ED4FA8500F26237 /* PinDisappearingMessageViewController.swift */; };
048D58382EDDD57E00F26237 /* OutgoingPinMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048D58372EDDD56D00F26237 /* OutgoingPinMessage.swift */; };
04A19B432EEC815200CCB105 /* PinnedMessageExpirationJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04A19B422EEC814900CCB105 /* PinnedMessageExpirationJob.swift */; };
@ -4204,6 +4206,8 @@
047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = "<group>"; };
0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = "<group>"; };
0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteRecord.swift; sourceTree = "<group>"; };
0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteManager.swift; sourceTree = "<group>"; };
0484CED12F44DCFF009AB2CB /* OutgoingAdminDeleteMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingAdminDeleteMessage.swift; sourceTree = "<group>"; };
048D58352ED4FA8500F26237 /* PinDisappearingMessageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinDisappearingMessageViewController.swift; sourceTree = "<group>"; };
048D58372EDDD56D00F26237 /* OutgoingPinMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingPinMessage.swift; sourceTree = "<group>"; };
04A19B422EEC814900CCB105 /* PinnedMessageExpirationJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessageExpirationJob.swift; sourceTree = "<group>"; };
@ -8468,7 +8472,9 @@
0484CECC2F44B7B4009AB2CB /* AdminDelete */ = {
isa = PBXGroup;
children = (
0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */,
0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */,
0484CED12F44DCFF009AB2CB /* OutgoingAdminDeleteMessage.swift */,
);
path = AdminDelete;
sourceTree = "<group>";
@ -18855,6 +18861,7 @@
D9EDF2782E4D2A2F001D4BEC /* AccountEntropyPoolManager.swift in Sources */,
C14D49CE2D667F830033BA69 /* AccountKeyStore.swift in Sources */,
50BE67532CAAF7DF006D7BC7 /* AdHocCallRecordManager.swift in Sources */,
0484CED02F44BD00009AB2CB /* AdminDeleteManager.swift in Sources */,
0484CECE2F44B7BE009AB2CB /* AdminDeleteRecord.swift in Sources */,
728BFE4C2C5C3427008F20F1 /* Aes256Key.swift in Sources */,
667DEE5F2BC7175300EFF32D /* AllMediaCategory.swift in Sources */,
@ -19460,6 +19467,7 @@
663F81D92E78C2740033D2AE /* OrphanedBackupAttachmentScheduler.swift in Sources */,
668478F92CAB687600430D68 /* OrphanedBackupAttachmentStore.swift in Sources */,
F9C5CD9F289453B400548EEE /* OutageDetection.swift in Sources */,
0484CED22F44DD01009AB2CB /* OutgoingAdminDeleteMessage.swift in Sources */,
50C55EB12F1ACCD700BA8309 /* OutgoingBlockedSyncMessage.swift in Sources */,
6640639E294D20A900997E0B /* OutgoingCallEventSyncMessage.swift in Sources */,
D979CC292AD3933B006AAC49 /* OutgoingCallEventSyncMessageManager.swift in Sources */,

View File

@ -476,11 +476,26 @@ public class CVComponentBodyText: CVComponentBase, CVComponent {
}
private var labelConfigForRemotelyDeleted: CVLabelConfig {
let text = (
isIncoming
? OWSLocalizedString("THIS_MESSAGE_WAS_DELETED", comment: "text indicating the message was remotely deleted")
: OWSLocalizedString("YOU_DELETED_THIS_MESSAGE", comment: "text indicating the message was remotely deleted by you"),
)
var text: String
switch bodyText {
case .remotelyDeleted(let deleteAuthor):
if let deleteAuthor {
// TODO: make attributed string with icon and tappable display name.
let format = OWSLocalizedString(
"DELETED_BY_ADMIN",
comment: "Text indicating the message was remotely deleted by an admin. Embeds {{admin display name}}",
)
text = String(format: format, deleteAuthor)
} else {
fallthrough
}
default:
text = (
isIncoming
? OWSLocalizedString("THIS_MESSAGE_WAS_DELETED", comment: "text indicating the message was remotely deleted")
: OWSLocalizedString("YOU_DELETED_THIS_MESSAGE", comment: "text indicating the message was remotely deleted by you"),
)
}
return CVLabelConfig(
text: .text(text),
displayConfig: .forUnstyledText(font: textMessageFont.italic(), textColor: bodyTextColor),

View File

@ -133,7 +133,7 @@ public struct CVComponentState: Equatable {
// We use the "body text" component to
// render the "remotely deleted" indicator.
case remotelyDeleted
case remotelyDeleted(deleteAuthorName: String?)
var displayableText: DisplayableText? {
switch self {
@ -1187,6 +1187,34 @@ private extension CVComponentState.Builder {
return FailedOrPendingDownloads(attachmentPointers: attachmentPointers)
}
/// If the message was deleted remotely by an admin, display the admin's name.
private func displayNameForDeleteMessage(message: TSMessage) -> String? {
let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager
guard
let authorAci = adminDeleteManager.adminDeleteAuthor(
interactionId: interaction.sqliteRowId!,
tx: transaction,
)
else {
return nil
}
if authorAci == localAci, message.isOutgoing {
// Display usual self delete message for outgoing self-deletion.
return nil
} else if let incomingMessage = message as? TSIncomingMessage, incomingMessage.authorAddress.aci == authorAci {
// Display usual other user delete for incoming self-deletion.
return nil
} else {
// Only display admin name if non self-delete.
return SSKEnvironment.shared.contactManagerRef.displayName(
for: SignalServiceAddress(authorAci),
tx: transaction,
).resolvedValue()
}
}
mutating func populateAndBuild(
message: TSMessage,
revealedSpoilerIdsSnapshot: Set<StyleIdType>,
@ -1194,7 +1222,9 @@ private extension CVComponentState.Builder {
if message.wasRemotelyDeleted {
// If the message has been remotely deleted, suppress everything else.
self.bodyText = .remotelyDeleted
let deleteAuthor = displayNameForDeleteMessage(message: message)
self.bodyText = .remotelyDeleted(deleteAuthorName: deleteAuthor)
return build()
}

View File

@ -5,6 +5,7 @@
public import SignalServiceKit
public import SignalUI
import LibSignalClient
public struct CVSelectionType: OptionSet {
public let rawValue: UInt
@ -312,6 +313,7 @@ extension ConversationViewController {
func didTapDeleteSelectedItems() {
let db = DependenciesBridge.shared.db
let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager
let selectionItems = self.selectionState.selectionItems
guard !selectionItems.isEmpty else {
@ -361,10 +363,16 @@ extension ConversationViewController {
let canDeleteForEveryone: Bool = db.read { tx in
selectionItems.allSatisfy { selectionItem in
TSOutgoingMessage.fetchOutgoingMessageViaCache(
uniqueId: selectionItem.interactionId,
transaction: tx,
)?.canBeRemotelyDeleted ?? false
guard
let message = TSMessage.fetchMessageViaCache(
uniqueId: selectionItem.interactionId,
transaction: tx,
)
else {
return false
}
let canAdminDelete = adminDeleteManager.canAdminDeleteMessage(message: message, thread: thread, tx: tx)
return message.canBeRemotelyDeletedByNonAdmin || canAdminDelete
}
}
@ -429,18 +437,20 @@ extension ConversationViewController {
thread: TSThread,
tx: DBWriteTransaction,
) {
let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager
guard !selectionItems.isEmpty else { return }
guard let latestThread = TSThread.fetchViaCache(uniqueId: thread.uniqueId, transaction: tx) else {
return owsFailDebug("Trying to delete messages without a thread.")
}
guard let aci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aci else {
return owsFailDebug("Local ACI missing during message deletion.")
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx) else {
return owsFailDebug("LocalIdentifiers missing during message deletion.")
}
selectionItems.forEach {
guard
let message = TSOutgoingMessage.fetchOutgoingMessageViaCache(
let message = TSMessage.fetchMessageViaCache(
uniqueId: $0.interactionId,
transaction: tx,
)
@ -448,20 +458,66 @@ extension ConversationViewController {
return
}
let deleteMessage = OutgoingDeleteMessage(thread: latestThread, message: message, tx: tx)
let canAdminDelete = adminDeleteManager.canAdminDeleteMessage(message: message, thread: thread, tx: tx)
message.updateWithRecipientAddressStates(
guard
let deleteMessage = TSInteraction.buildDeleteMessage(
thread: thread,
message: message,
localIdentifiers: localIdentifiers,
canAdminDelete: canAdminDelete,
tx: tx,
)
else {
return owsFailDebug("Failure to build outgoing delete for everyone.")
}
// TODO: pending send state for incoming messages
(message as? TSOutgoingMessage)?.updateWithRecipientAddressStates(
deleteMessage.recipientAddressStates,
tx: tx,
)
_ = TSMessage.tryToRemotelyDeleteMessage(
fromAuthor: aci,
sentAtTimestamp: message.timestamp,
threadUniqueId: latestThread.uniqueId,
serverTimestamp: 0, // TSOutgoingMessage won't have server timestamp.
transaction: tx,
)
if message.canBeRemotelyDeletedByNonAdmin {
_ = TSMessage.tryToRemotelyDeleteMessageAsNonAdmin(
fromAuthor: localIdentifiers.aci,
sentAtTimestamp: message.timestamp,
threadUniqueId: latestThread.uniqueId,
serverTimestamp: 0, // TSOutgoingMessage won't have server timestamp.
transaction: tx,
)
} else if
canAdminDelete,
let groupThread = thread as? TSGroupThread
{
let originalMessageAuthorAci: Aci?
let serverTimestamp: UInt64
if let incomingMessage = (message as? TSIncomingMessage) {
serverTimestamp = incomingMessage.serverTimestamp?.uint64Value ?? 0
originalMessageAuthorAci = incomingMessage.authorAddress.aci
} else {
originalMessageAuthorAci = localIdentifiers.aci
serverTimestamp = 0
}
guard let originalMessageAuthorAci else {
owsFailDebug("Unable to admin delete without original message author")
return
}
_ = DependenciesBridge.shared.adminDeleteManager.tryToAdminDeleteMessage(
originalMessageAuthorAci: originalMessageAuthorAci,
deleteAuthorAci: localIdentifiers.aci,
sentAtTimestamp: message.timestamp,
groupThread: groupThread,
threadUniqueId: latestThread.uniqueId,
serverTimestamp: serverTimestamp,
transaction: tx,
)
} else {
owsFailDebug("Unable to delete as admin or as non-admin")
return
}
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: deleteMessage,

View File

@ -5,6 +5,7 @@
import Foundation
public import SignalServiceKit
import LibSignalClient
import SignalUI
import UIKit
@ -88,6 +89,134 @@ public extension TSInteraction {
fromViewController.presentActionSheet(actionSheet)
}
/// If the local user is an admin, we prefer regular remote delete. We only fallback to
/// admin delete if the remote delete timeframe has expired and admin delete timeframe has not.
class func buildDeleteMessage(
thread: TSThread,
message: TSMessage,
localIdentifiers: LocalIdentifiers,
canAdminDelete: Bool,
tx: DBReadTransaction,
) -> TransientOutgoingMessage? {
if
message.canBeRemotelyDeletedByNonAdmin,
let outgoingMessage = message as? TSOutgoingMessage
{
return OutgoingDeleteMessage(thread: thread, message: outgoingMessage, tx: tx)
}
guard canAdminDelete else {
owsFailDebug("Unable to admin-delete incoming message")
return nil
}
return OutgoingAdminDeleteMessage(
thread: thread,
message: message,
localIdentifiers: localIdentifiers,
tx: tx,
)
}
private func buildDeleteForEveryoneAction(thread: TSThread) -> ActionSheetAction? {
let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager
let db = DependenciesBridge.shared.db
guard let message = self as? TSMessage else {
return nil
}
let canAdminDelete = db.read { tx in adminDeleteManager.canAdminDeleteMessage(message: message, thread: thread, tx: tx) }
if message.canBeRemotelyDeletedByNonAdmin || canAdminDelete {
return ActionSheetAction(
title: CommonStrings.deleteForEveryoneButton,
style: .destructive,
) { [weak self] _ in
guard self != nil else { return }
Self.showDeleteForEveryoneConfirmationIfNecessary {
SSKEnvironment.shared.databaseStorageRef.write { tx in
let latestMessage = TSMessage.fetchMessageViaCache(
uniqueId: message.uniqueId,
transaction: tx,
)
guard let latestMessage, let latestThread = latestMessage.thread(tx: tx) else {
// We can't reach this point in the UI if a message doesn't have a thread.
return owsFailDebug("Trying to delete a message without a thread.")
}
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx) else {
return owsFailDebug("LocalIdentifiers missing during message deletion.")
}
guard
let deleteMessage = Self.buildDeleteMessage(
thread: latestThread,
message: latestMessage,
localIdentifiers: localIdentifiers,
canAdminDelete: canAdminDelete,
tx: tx,
)
else {
return owsFailDebug("Failure to build outgoing delete for everyone.")
}
// Reset the sending states, so we can render the sending state of the
// deleted message. OutgoingDeleteMessage will automatically pass through
// it's send state to the message record that it is deleting.
// TODO: support sending state animation for incoming messages.
(latestMessage as? TSOutgoingMessage)?.updateWithRecipientAddressStates(deleteMessage.recipientAddressStates, tx: tx)
if message.canBeRemotelyDeletedByNonAdmin {
_ = TSMessage.tryToRemotelyDeleteMessageAsNonAdmin(
fromAuthor: localIdentifiers.aci,
sentAtTimestamp: latestMessage.timestamp,
threadUniqueId: latestThread.uniqueId,
serverTimestamp: 0, // TSOutgoingMessage won't have server timestamp.
transaction: tx,
)
} else if
canAdminDelete,
let groupThread = thread as? TSGroupThread
{
let originalMessageAuthorAci: Aci?
let serverTimestamp: UInt64
if let incomingMessage = (latestMessage as? TSIncomingMessage) {
serverTimestamp = incomingMessage.serverTimestamp?.uint64Value ?? 0
originalMessageAuthorAci = incomingMessage.authorAddress.aci
} else {
originalMessageAuthorAci = localIdentifiers.aci
serverTimestamp = 0
}
guard let originalMessageAuthorAci else {
owsFailDebug("Unable to admin delete without original message author")
return
}
_ = DependenciesBridge.shared.adminDeleteManager.tryToAdminDeleteMessage(
originalMessageAuthorAci: originalMessageAuthorAci,
deleteAuthorAci: localIdentifiers.aci,
sentAtTimestamp: latestMessage.timestamp,
groupThread: groupThread,
threadUniqueId: latestThread.uniqueId,
serverTimestamp: serverTimestamp,
transaction: tx,
)
} else {
owsFailDebug("Unable to delete as admin or as non-admin")
return
}
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: deleteMessage,
)
SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx)
}
}
}
}
return nil
}
private func presentDeletionActionSheetForNotNoteToSelf(
fromViewController: UIViewController,
thread: TSThread,
@ -108,54 +237,7 @@ public extension TSInteraction {
thread: thread,
))
if
let outgoingMessage = self as? TSOutgoingMessage,
outgoingMessage.canBeRemotelyDeleted
{
let deleteForEveryoneAction = ActionSheetAction(
title: CommonStrings.deleteForEveryoneButton,
style: .destructive,
) { [weak self] _ in
guard self != nil else { return }
Self.showDeleteForEveryoneConfirmationIfNecessary {
SSKEnvironment.shared.databaseStorageRef.write { tx in
let latestMessage = TSOutgoingMessage.fetchOutgoingMessageViaCache(
uniqueId: outgoingMessage.uniqueId,
transaction: tx,
)
guard let latestMessage, let latestThread = latestMessage.thread(tx: tx) else {
// We can't reach this point in the UI if a message doesn't have a thread.
return owsFailDebug("Trying to delete a message without a thread.")
}
let deleteMessage = OutgoingDeleteMessage(
thread: latestThread,
message: latestMessage,
tx: tx,
)
// Reset the sending states, so we can render the sending state of the
// deleted message. OutgoingDeleteMessage will automatically pass through
// it's send state to the message record that it is deleting.
latestMessage.updateWithRecipientAddressStates(deleteMessage.recipientAddressStates, tx: tx)
if let aci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aci {
_ = TSMessage.tryToRemotelyDeleteMessage(
fromAuthor: aci,
sentAtTimestamp: latestMessage.timestamp,
threadUniqueId: latestThread.uniqueId,
serverTimestamp: 0, // TSOutgoingMessage won't have server timestamp.
transaction: tx,
)
} else {
owsFailDebug("Local ACI missing during message deletion.")
}
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: deleteMessage,
)
SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx)
}
}
}
if let deleteForEveryoneAction = buildDeleteForEveryoneAction(thread: thread) {
actionSheetController.addAction(deleteForEveryoneAction)
}

View File

@ -2746,6 +2746,9 @@
/* Subheader for an action sheet explaining that a Note to Self deleted on this device will be deleted on the user's other devices as well. */
"DELETE_FOR_ME_NOTE_TO_SELF_LINKED_DEVICES_PRESENT_ACTION_SHEET_SUBHEADER" = "This message will be deleted from all your devices.";
/* Text indicating the message was remotely deleted by an admin. Embeds {{admin display name}} */
"DELETED_BY_ADMIN" = "Admin %@ deleted this message";
/* Label indicating a user who deleted their account. */
"DELETED_USER" = "Deleted Account";

View File

@ -1649,10 +1649,16 @@ extension AppSetup.GlobalsContinuation {
tsAccountManager: tsAccountManager,
)
let adminDeleteManager = AdminDeleteManager(
recipientDatabaseTable: recipientDatabaseTable,
tsAccountManager: tsAccountManager,
)
let dependenciesBridge = DependenciesBridge(
accountAttributesUpdater: accountAttributesUpdater,
accountEntropyPoolManager: accountEntropyPoolManager,
adHocCallRecordManager: adHocCallRecordManager,
adminDeleteManager: adminDeleteManager,
appExpiry: appExpiry,
attachmentContentValidator: attachmentContentValidator,
attachmentDownloadManager: attachmentDownloadManager,

View File

@ -90,6 +90,11 @@ public enum BuildFlags {
}
public static let pollOneOnOneSend = build <= .internal
public enum AdminDelete {
public static let receive = build <= .dev
public static let send = build <= .dev
}
}
// MARK: -

View File

@ -49,6 +49,7 @@ public class DependenciesBridge {
public let accountAttributesUpdater: AccountAttributesUpdater
public let accountEntropyPoolManager: AccountEntropyPoolManager
public let adHocCallRecordManager: any AdHocCallRecordManager
public let adminDeleteManager: AdminDeleteManager
public let appExpiry: AppExpiry
public let attachmentContentValidator: AttachmentContentValidator
public let attachmentDownloadManager: AttachmentDownloadManager
@ -190,6 +191,7 @@ public class DependenciesBridge {
accountAttributesUpdater: AccountAttributesUpdater,
accountEntropyPoolManager: AccountEntropyPoolManager,
adHocCallRecordManager: any AdHocCallRecordManager,
adminDeleteManager: AdminDeleteManager,
appExpiry: AppExpiry,
attachmentContentValidator: AttachmentContentValidator,
attachmentDownloadManager: AttachmentDownloadManager,
@ -330,6 +332,7 @@ public class DependenciesBridge {
self.accountAttributesUpdater = accountAttributesUpdater
self.accountEntropyPoolManager = accountEntropyPoolManager
self.adHocCallRecordManager = adHocCallRecordManager
self.adminDeleteManager = adminDeleteManager
self.appExpiry = appExpiry
self.attachmentContentValidator = attachmentContentValidator
self.attachmentDownloadManager = attachmentDownloadManager

View File

@ -0,0 +1,157 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import LibSignalClient
import GRDB
public class AdminDeleteManager {
let recipientDatabaseTable: RecipientDatabaseTable
let tsAccountManager: TSAccountManager
private let logger = PrefixedLogger(prefix: "AdminDelete")
init(
recipientDatabaseTable: RecipientDatabaseTable,
tsAccountManager: TSAccountManager,
) {
self.recipientDatabaseTable = recipientDatabaseTable
self.tsAccountManager = tsAccountManager
}
public enum AdminDeleteProcessingResult: Error {
case deletedMessageMissing
case invalidDelete
case success
}
private func insertAdminDelete(
groupThread: TSGroupThread,
interactionId: Int64,
deleteAuthor: Aci,
tx: DBWriteTransaction,
) -> AdminDeleteProcessingResult {
guard
let groupModel = groupThread.groupModel as? TSGroupModelV2,
groupModel.membership.isFullMemberAndAdministrator(deleteAuthor)
else {
logger.error("Failed to process admin delete for non-admin")
return .invalidDelete
}
guard
let deleteAuthorId = recipientDatabaseTable.fetchRecipient(
serviceId: deleteAuthor,
transaction: tx,
)?.id
else {
logger.error("Failed to process admin delete for missing signal recipient")
return .invalidDelete
}
failIfThrows {
try AdminDeleteRecord.insertRecord(
interactionId: interactionId,
deleteAuthorId: deleteAuthorId,
tx: tx,
)
}
return .success
}
public func tryToAdminDeleteMessage(
originalMessageAuthorAci: Aci,
deleteAuthorAci: Aci,
sentAtTimestamp: UInt64,
groupThread: TSGroupThread,
threadUniqueId: String?,
serverTimestamp: UInt64,
transaction: DBWriteTransaction,
) -> AdminDeleteProcessingResult {
guard SDS.fitsInInt64(sentAtTimestamp) else {
owsFailDebug("Unable to delete a message with invalid sentAtTimestamp: \(sentAtTimestamp)")
return .invalidDelete
}
if
let threadUniqueId, let messageToDelete = InteractionFinder.findMessage(
withTimestamp: sentAtTimestamp,
threadId: threadUniqueId,
author: SignalServiceAddress(originalMessageAuthorAci),
transaction: transaction,
)
{
let success = TSMessage.remotelyDeleteMessage(
messageToDelete,
authorAci: originalMessageAuthorAci,
isAdminDelete: true,
serverTimestamp: serverTimestamp,
transaction: transaction,
)
guard success else {
return .invalidDelete
}
return insertAdminDelete(
groupThread: groupThread,
interactionId: messageToDelete.sqliteRowId!,
deleteAuthor: deleteAuthorAci,
tx: transaction,
)
} else {
return .deletedMessageMissing
}
}
public func adminDeleteAuthor(interactionId: Int64, tx: DBReadTransaction) -> Aci? {
guard BuildFlags.AdminDelete.receive else {
return nil
}
return failIfThrows {
guard
let adminDeleteRecord = try AdminDeleteRecord
.filter(AdminDeleteRecord.Columns.interactionId == interactionId)
.fetchOne(tx.database)
else {
return nil
}
let signalRecipient = recipientDatabaseTable.fetchRecipient(
rowId: adminDeleteRecord.deleteAuthorId,
tx: tx,
)
return signalRecipient?.aci
}
}
public func canAdminDeleteMessage(
message: TSMessage,
thread: TSThread,
tx: DBReadTransaction,
) -> Bool {
guard BuildFlags.AdminDelete.send else {
return false
}
guard let groupThread = thread as? TSGroupThread else {
return false
}
guard let localAci = tsAccountManager.localIdentifiers(tx: tx)?.aci else {
return false
}
guard groupThread.groupModel.groupMembership.isFullMemberAndAdministrator(localAci) else {
return false
}
guard message.canBeRemotelyDeletedByAdmin else {
return false
}
return true
}
}

View File

@ -0,0 +1,116 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
public final class OutgoingAdminDeleteMessage: TransientOutgoingMessage {
let originalMessageTimestamp: UInt64
let originalMessageAuthor: Aci?
let originalMessageUniqueId: String
public init(
thread: TSThread,
message: TSMessage,
localIdentifiers: LocalIdentifiers,
tx: DBReadTransaction,
) {
owsAssertDebug(thread.uniqueId == message.uniqueThreadId)
self.originalMessageTimestamp = message.timestamp
if let incomingMessage = message as? TSIncomingMessage {
self.originalMessageAuthor = incomingMessage.authorAddress.aci
} else {
self.originalMessageAuthor = localIdentifiers.aci
}
self.originalMessageUniqueId = message.uniqueId
super.init(
outgoingMessageWith: TSOutgoingMessageBuilder.outgoingMessageBuilder(thread: thread),
additionalRecipients: [],
explicitRecipients: [],
skippedRecipients: [],
transaction: tx,
)
}
override public class var supportsSecureCoding: Bool { true }
override public func encode(with coder: NSCoder) {
super.encode(with: coder)
coder.encode(NSNumber(value: self.originalMessageTimestamp), forKey: "originalMessageTimestamp")
if let originalMessageAuthor {
coder.encode(originalMessageAuthor.serviceIdBinary, forKey: "originalMessageAuthorBinary")
}
coder.encode(originalMessageUniqueId, forKey: "originalMessageUniqueId")
}
public required init?(coder: NSCoder) {
guard
let originalMessageTimestamp = coder.decodeObject(of: NSNumber.self, forKey: "originalMessageTimestamp"),
let originalMessageAuthorBinary = coder.decodeObject(of: NSData.self, forKey: "originalMessageAuthorBinary") as Data?,
let originalMessageUniqueId = coder.decodeObject(of: NSString.self, forKey: "originalMessageUniqueId") as String?
else {
return nil
}
self.originalMessageTimestamp = originalMessageTimestamp.uint64Value
self.originalMessageAuthor = try? Aci.parseFrom(serviceIdBinary: originalMessageAuthorBinary)
self.originalMessageUniqueId = originalMessageUniqueId
super.init(coder: coder)
}
override public var hash: Int {
var hasher = Hasher()
hasher.combine(super.hash)
hasher.combine(self.originalMessageTimestamp)
hasher.combine(self.originalMessageAuthor)
hasher.combine(self.originalMessageUniqueId)
return hasher.finalize()
}
override public func isEqual(_ object: Any?) -> Bool {
guard let object = object as? Self else { return false }
guard super.isEqual(object) else { return false }
guard self.originalMessageTimestamp == object.originalMessageTimestamp else { return false }
guard self.originalMessageAuthor == object.originalMessageAuthor else { return false }
guard self.originalMessageUniqueId == object.originalMessageUniqueId else { return false }
return true
}
override public func dataMessageBuilder(with thread: TSThread, transaction: DBReadTransaction) -> SSKProtoDataMessageBuilder? {
guard let originalMessageAuthor else {
return nil
}
let adminDeleteBuilder = SSKProtoDataMessageAdminDelete.builder()
adminDeleteBuilder.setTargetAuthorAciBinary(originalMessageAuthor.serviceIdBinary)
adminDeleteBuilder.setTargetSentTimestamp(originalMessageTimestamp)
let builder = super.dataMessageBuilder(with: thread, transaction: transaction)
builder?.setTimestamp(self.timestamp)
builder?.setAdminDelete(adminDeleteBuilder.buildInfallibly())
return builder
}
override public func anyUpdateOutgoingMessage(transaction: DBWriteTransaction, block: (TSOutgoingMessage) -> Void) {
super.anyUpdateOutgoingMessage(transaction: transaction, block: block)
let deletedMessage = TSMessage.fetchMessageViaCache(
uniqueId: originalMessageUniqueId,
transaction: transaction,
)
if let outgoingDeletedMessage = deletedMessage as? TSOutgoingMessage {
outgoingDeletedMessage.updateWithRecipientAddressStates(self.recipientAddressStates, tx: transaction)
}
}
override public var relatedUniqueIds: Set<String> {
return super.relatedUniqueIds.union([self.originalMessageUniqueId].compacted())
}
// TODO: revert local state if send to everyone fails
}

View File

@ -311,12 +311,23 @@ public extension TSMessage {
// * it's not a message with a gift badge
// * it has been less than 24 hours since you sent the message
// * this includes messages sent in the future
var canBeRemotelyDeleted: Bool {
var canBeRemotelyDeletedByNonAdmin: Bool {
guard let outgoingMessage = self as? TSOutgoingMessage else { return false }
guard !outgoingMessage.wasRemotelyDeleted else { return false }
guard outgoingMessage.giftBadge == nil else { return false }
let (elapsedTime, isInFuture) = Date.ows_millisecondTimestamp().subtractingReportingOverflow(outgoingMessage.timestamp)
// TODO: replace with global config
guard isInFuture || (elapsedTime <= UInt64.dayInMs) else { return false }
return true
}
var canBeRemotelyDeletedByAdmin: Bool {
let (elapsedTime, isInFuture) = Date.ows_millisecondTimestamp().subtractingReportingOverflow(self.timestamp)
// TODO: replace with global config
guard isInFuture || (elapsedTime <= UInt64.dayInMs) else { return false }
return true
@ -329,7 +340,67 @@ public extension TSMessage {
case success
}
class func tryToRemotelyDeleteMessage(
static func remotelyDeleteMessage(
_ message: TSMessage,
authorAci: Aci,
isAdminDelete: Bool,
serverTimestamp: UInt64,
transaction: DBWriteTransaction,
) -> Bool {
if message is TSOutgoingMessage {
if SignalServiceAddress(authorAci).isLocalAddress || isAdminDelete {
message.markMessageAsRemotelyDeleted(transaction: transaction)
return true
}
owsFailDebug("Can't delete an outgoing message by non-local user unless its an admin delete.")
return false
} else if var incomingMessageToDelete = message as? TSIncomingMessage {
if incomingMessageToDelete.editState == .pastRevision {
// The remote delete targeted an old revision, fetch
// swap out the target message for the latest (or return an error)
// This avoids cases where older edits could be deleted and
// leave newer revisions
if
let latestEdit = DependenciesBridge.shared.editMessageStore.findMessage(
fromEdit: incomingMessageToDelete,
tx: transaction,
) as? TSIncomingMessage
{
incomingMessageToDelete = latestEdit
} else {
Logger.info("Ignoring delete for missing edit target.")
return false
}
}
guard let messageToDeleteServerTimestamp = incomingMessageToDelete.serverTimestamp?.uint64Value else {
// Older messages might be missing this, but since we only allow deleting for a small
// window after you send a message we should generally never hit this path.
owsFailDebug("can't delete a message without a serverTimestamp")
return false
}
guard messageToDeleteServerTimestamp <= serverTimestamp else {
owsFailDebug("Can't delete a message from the future.")
return false
}
// TODO: This should eventually be determined via global remote config separated by admin vs regular remote delete.
guard serverTimestamp - messageToDeleteServerTimestamp < (2 * UInt64.dayInMs) else {
owsFailDebug("Ignoring message delete sent more than 48 hours after the original message")
return false
}
incomingMessageToDelete.markMessageAsRemotelyDeleted(transaction: transaction)
return true
} else {
owsFailDebug("Message to delete is not incoming or outgoing")
return false
}
}
class func tryToRemotelyDeleteMessageAsNonAdmin(
fromAuthor authorAci: Aci,
sentAtTimestamp: UInt64,
threadUniqueId: String?,
@ -349,52 +420,15 @@ public extension TSMessage {
transaction: transaction,
)
{
if messageToDelete is TSOutgoingMessage, SignalServiceAddress(authorAci).isLocalAddress {
messageToDelete.markMessageAsRemotelyDeleted(transaction: transaction)
return .success
} else if var incomingMessageToDelete = messageToDelete as? TSIncomingMessage {
if incomingMessageToDelete.editState == .pastRevision {
// The remote delete targeted an old revision, fetch
// swap out the target message for the latest (or return an error)
// This avoids cases where older edits could be deleted and
// leave newer revisions
if
let latestEdit = DependenciesBridge.shared.editMessageStore.findMessage(
fromEdit: incomingMessageToDelete,
tx: transaction,
) as? TSIncomingMessage
{
incomingMessageToDelete = latestEdit
} else {
Logger.info("Ignoring delete for missing edit target.")
return .invalidDelete
}
}
let success = remotelyDeleteMessage(
messageToDelete,
authorAci: authorAci,
isAdminDelete: false,
serverTimestamp: serverTimestamp,
transaction: transaction,
)
guard let messageToDeleteServerTimestamp = incomingMessageToDelete.serverTimestamp?.uint64Value else {
// Older messages might be missing this, but since we only allow deleting for a small
// window after you send a message we should generally never hit this path.
owsFailDebug("can't delete a message without a serverTimestamp")
return .invalidDelete
}
guard messageToDeleteServerTimestamp < serverTimestamp else {
owsFailDebug("Can't delete a message from the future.")
return .invalidDelete
}
guard serverTimestamp - messageToDeleteServerTimestamp < (2 * UInt64.dayInMs) else {
owsFailDebug("Ignoring message delete sent more than 48 hours after the original message")
return .invalidDelete
}
incomingMessageToDelete.markMessageAsRemotelyDeleted(transaction: transaction)
return .success
} else {
owsFailDebug("Only incoming messages can be deleted remotely")
return .invalidDelete
}
return success ? .success : .invalidDelete
} else if
let storyMessage = StoryFinder.story(
timestamp: sentAtTimestamp,

View File

@ -468,7 +468,7 @@ public final class MessageReceiver {
)
}
} else if let delete = dataMessage.delete {
let result = TSMessage.tryToRemotelyDeleteMessage(
let result = TSMessage.tryToRemotelyDeleteMessageAsNonAdmin(
fromAuthor: decryptedEnvelope.sourceAci,
sentAtTimestamp: delete.targetSentTimestamp,
threadUniqueId: transcript.threadForDataMessage?.uniqueId,
@ -491,6 +491,52 @@ public final class MessageReceiver {
transaction: tx,
)
}
} else if let adminDelete = dataMessage.adminDelete {
guard BuildFlags.AdminDelete.receive else {
Logger.warn("Dropping admin delete message because build flag is not enabled")
return
}
let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager
let earlyMessageManager = SSKEnvironment.shared.earlyMessageManagerRef
guard let groupThread = transcript.threadForDataMessage as? TSGroupThread else {
owsFailDebug("Could not process admin delete thread from sync transcript.")
return
}
guard
let targetAuthorAciBinary = adminDelete.targetAuthorAciBinary,
let targetAuthorAci = try? Aci.parseFrom(serviceIdBinary: targetAuthorAciBinary)
else {
Logger.error("Couldn't process admin delete for invalid aci")
return
}
let result = adminDeleteManager.tryToAdminDeleteMessage(
originalMessageAuthorAci: targetAuthorAci,
deleteAuthorAci: localIdentifiers.aci,
sentAtTimestamp: adminDelete.targetSentTimestamp,
groupThread: groupThread,
threadUniqueId: groupThread.uniqueId,
serverTimestamp: envelope.serverTimestamp,
transaction: tx,
)
switch result {
case .success:
break
case .invalidDelete:
Logger.warn("Couldn't process invalid admin delete")
case .deletedMessageMissing:
earlyMessageManager.recordEarlyEnvelope(
envelope,
plainTextData: request.plaintextData,
wasReceivedByUD: request.wasReceivedByUD,
serverDeliveryTimestamp: request.serverDeliveryTimestamp,
associatedMessageTimestamp: adminDelete.targetSentTimestamp,
associatedMessageAuthor: decryptedEnvelope.sourceAci,
transaction: tx,
)
}
} else if let groupCallUpdate = dataMessage.groupCallUpdate {
if let groupId {
let pendingTask = MessageReceiver.buildPendingTask()
@ -1081,7 +1127,7 @@ public final class MessageReceiver {
}
if let delete = dataMessage.delete {
let result = TSMessage.tryToRemotelyDeleteMessage(
let result = TSMessage.tryToRemotelyDeleteMessageAsNonAdmin(
fromAuthor: envelope.sourceAci,
sentAtTimestamp: delete.targetSentTimestamp,
threadUniqueId: thread.uniqueId,
@ -1107,6 +1153,54 @@ public final class MessageReceiver {
return nil
}
if let adminDelete = dataMessage.adminDelete {
guard BuildFlags.AdminDelete.receive else {
Logger.warn("Dropping admin delete message because build flag is not enabled")
return nil
}
let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager
guard let groupThread = (thread as? TSGroupThread) else {
Logger.error("Couldn't process admin delete for non-group thread")
return nil
}
guard
let targetAuthorAciBinary = adminDelete.targetAuthorAciBinary,
let targetAuthorAci = try? Aci.parseFrom(serviceIdBinary: targetAuthorAciBinary)
else {
Logger.error("Couldn't process admin delete for invalid aci")
return nil
}
let result = adminDeleteManager.tryToAdminDeleteMessage(
originalMessageAuthorAci: targetAuthorAci,
deleteAuthorAci: envelope.sourceAci,
sentAtTimestamp: adminDelete.targetSentTimestamp,
groupThread: groupThread,
threadUniqueId: thread.uniqueId,
serverTimestamp: envelope.serverTimestamp,
transaction: tx,
)
switch result {
case .success:
break
case .invalidDelete:
Logger.warn("Couldn't process invalid admin delete")
case .deletedMessageMissing:
SSKEnvironment.shared.earlyMessageManagerRef.recordEarlyEnvelope(
envelope.envelope,
plainTextData: request.plaintextData,
wasReceivedByUD: request.wasReceivedByUD,
serverDeliveryTimestamp: request.serverDeliveryTimestamp,
associatedMessageTimestamp: adminDelete.targetSentTimestamp,
associatedMessageAuthor: envelope.sourceAci,
transaction: tx,
)
}
return nil
}
if let pollVote = dataMessage.pollVote {
do {
guard

View File

@ -8839,6 +8839,177 @@ extension SSKProtoDataMessageUnpinMessageBuilder {
#endif
// MARK: - SSKProtoDataMessageAdminDelete
@objc
public class SSKProtoDataMessageAdminDelete: NSObject, Codable, NSSecureCoding {
fileprivate let proto: SignalServiceProtos_DataMessage.AdminDelete
@objc
public var targetAuthorAciBinary: Data? {
guard hasTargetAuthorAciBinary else {
return nil
}
return proto.targetAuthorAciBinary
}
@objc
public var hasTargetAuthorAciBinary: Bool {
return proto.hasTargetAuthorAciBinary
}
@objc
public var targetSentTimestamp: UInt64 {
return proto.targetSentTimestamp
}
@objc
public var hasTargetSentTimestamp: Bool {
return proto.hasTargetSentTimestamp
}
public var hasUnknownFields: Bool {
return !proto.unknownFields.data.isEmpty
}
public var unknownFields: SwiftProtobuf.UnknownStorage? {
guard hasUnknownFields else { return nil }
return proto.unknownFields
}
private init(proto: SignalServiceProtos_DataMessage.AdminDelete) {
self.proto = proto
}
@objc
public func serializedData() throws -> Data {
return try self.proto.serializedData()
}
@objc
public required convenience init(serializedData: Data) throws {
let proto = try SignalServiceProtos_DataMessage.AdminDelete(serializedBytes: serializedData)
self.init(proto)
}
fileprivate convenience init(_ proto: SignalServiceProtos_DataMessage.AdminDelete) {
self.init(proto: proto)
}
public required convenience init(from decoder: Swift.Decoder) throws {
let singleValueContainer = try decoder.singleValueContainer()
let serializedData = try singleValueContainer.decode(Data.self)
try self.init(serializedData: serializedData)
}
public func encode(to encoder: Swift.Encoder) throws {
var singleValueContainer = encoder.singleValueContainer()
try singleValueContainer.encode(try serializedData())
}
public static var supportsSecureCoding: Bool { true }
public required convenience init?(coder: NSCoder) {
guard let serializedData = coder.decodeData() else { return nil }
do {
try self.init(serializedData: serializedData)
} catch {
owsFailDebug("Failed to decode serialized data \(error)")
return nil
}
}
public func encode(with coder: NSCoder) {
do {
coder.encode(try serializedData())
} catch {
owsFailDebug("Failed to encode serialized data \(error)")
}
}
@objc
public override var debugDescription: String {
return "\(proto)"
}
}
extension SSKProtoDataMessageAdminDelete {
@objc
public static func builder() -> SSKProtoDataMessageAdminDeleteBuilder {
return SSKProtoDataMessageAdminDeleteBuilder()
}
// asBuilder() constructs a builder that reflects the proto's contents.
@objc
public func asBuilder() -> SSKProtoDataMessageAdminDeleteBuilder {
let builder = SSKProtoDataMessageAdminDeleteBuilder()
if let _value = targetAuthorAciBinary {
builder.setTargetAuthorAciBinary(_value)
}
if hasTargetSentTimestamp {
builder.setTargetSentTimestamp(targetSentTimestamp)
}
if let _value = unknownFields {
builder.setUnknownFields(_value)
}
return builder
}
}
@objc
public class SSKProtoDataMessageAdminDeleteBuilder: NSObject {
private var proto = SignalServiceProtos_DataMessage.AdminDelete()
@objc
fileprivate override init() {}
@objc
@available(swift, obsoleted: 1.0)
public func setTargetAuthorAciBinary(_ valueParam: Data?) {
guard let valueParam = valueParam else { return }
proto.targetAuthorAciBinary = valueParam
}
public func setTargetAuthorAciBinary(_ valueParam: Data) {
proto.targetAuthorAciBinary = valueParam
}
@objc
public func setTargetSentTimestamp(_ valueParam: UInt64) {
proto.targetSentTimestamp = valueParam
}
public func setUnknownFields(_ unknownFields: SwiftProtobuf.UnknownStorage) {
proto.unknownFields = unknownFields
}
@objc
public func buildInfallibly() -> SSKProtoDataMessageAdminDelete {
return SSKProtoDataMessageAdminDelete(proto)
}
@objc
public func buildSerializedData() throws -> Data {
return try SSKProtoDataMessageAdminDelete(proto).serializedData()
}
}
#if TESTABLE_BUILD
extension SSKProtoDataMessageAdminDelete {
@objc
public func serializedDataIgnoringErrors() -> Data? {
return try! self.serializedData()
}
}
extension SSKProtoDataMessageAdminDeleteBuilder {
@objc
public func buildIgnoringErrors() -> SSKProtoDataMessageAdminDelete? {
return self.buildInfallibly()
}
}
#endif
// MARK: - SSKProtoDataMessageFlags
@objc
@ -8968,6 +9139,9 @@ public class SSKProtoDataMessage: NSObject, Codable, NSSecureCoding {
@objc
public let unpinMessage: SSKProtoDataMessageUnpinMessage?
@objc
public let adminDelete: SSKProtoDataMessageAdminDelete?
@objc
public var body: String? {
guard hasBody else {
@ -9072,7 +9246,8 @@ public class SSKProtoDataMessage: NSObject, Codable, NSSecureCoding {
pollTerminate: SSKProtoDataMessagePollTerminate?,
pollVote: SSKProtoDataMessagePollVote?,
pinMessage: SSKProtoDataMessagePinMessage?,
unpinMessage: SSKProtoDataMessageUnpinMessage?) {
unpinMessage: SSKProtoDataMessageUnpinMessage?,
adminDelete: SSKProtoDataMessageAdminDelete?) {
self.proto = proto
self.attachments = attachments
self.groupV2 = groupV2
@ -9092,6 +9267,7 @@ public class SSKProtoDataMessage: NSObject, Codable, NSSecureCoding {
self.pollVote = pollVote
self.pinMessage = pinMessage
self.unpinMessage = unpinMessage
self.adminDelete = adminDelete
}
@objc
@ -9188,6 +9364,11 @@ public class SSKProtoDataMessage: NSObject, Codable, NSSecureCoding {
unpinMessage = SSKProtoDataMessageUnpinMessage(proto.unpinMessage)
}
var adminDelete: SSKProtoDataMessageAdminDelete?
if proto.hasAdminDelete {
adminDelete = SSKProtoDataMessageAdminDelete(proto.adminDelete)
}
self.init(proto: proto,
attachments: attachments,
groupV2: groupV2,
@ -9206,7 +9387,8 @@ public class SSKProtoDataMessage: NSObject, Codable, NSSecureCoding {
pollTerminate: pollTerminate,
pollVote: pollVote,
pinMessage: pinMessage,
unpinMessage: unpinMessage)
unpinMessage: unpinMessage,
adminDelete: adminDelete)
}
public required convenience init(from decoder: Swift.Decoder) throws {
@ -9325,6 +9507,9 @@ extension SSKProtoDataMessage {
if let _value = unpinMessage {
builder.setUnpinMessage(_value)
}
if let _value = adminDelete {
builder.setAdminDelete(_value)
}
if let _value = unknownFields {
builder.setUnknownFields(_value)
}
@ -9586,6 +9771,17 @@ public class SSKProtoDataMessageBuilder: NSObject {
proto.unpinMessage = valueParam.proto
}
@objc
@available(swift, obsoleted: 1.0)
public func setAdminDelete(_ valueParam: SSKProtoDataMessageAdminDelete?) {
guard let valueParam = valueParam else { return }
proto.adminDelete = valueParam.proto
}
public func setAdminDelete(_ valueParam: SSKProtoDataMessageAdminDelete) {
proto.adminDelete = valueParam.proto
}
public func setUnknownFields(_ unknownFields: SwiftProtobuf.UnknownStorage) {
proto.unknownFields = unknownFields
}

View File

@ -1196,7 +1196,6 @@ struct SignalServiceProtos_DataMessage: @unchecked Sendable {
/// Clears the value of `pinMessage`. Subsequent reads from it will return its default value.
mutating func clearPinMessage() {_uniqueStorage()._pinMessage = nil}
/// NEXT ID: 29
var unpinMessage: SignalServiceProtos_DataMessage.UnpinMessage {
get {return _storage._unpinMessage ?? SignalServiceProtos_DataMessage.UnpinMessage()}
set {_uniqueStorage()._unpinMessage = newValue}
@ -1206,6 +1205,16 @@ struct SignalServiceProtos_DataMessage: @unchecked Sendable {
/// Clears the value of `unpinMessage`. Subsequent reads from it will return its default value.
mutating func clearUnpinMessage() {_uniqueStorage()._unpinMessage = nil}
/// NEXT ID: 30
var adminDelete: SignalServiceProtos_DataMessage.AdminDelete {
get {return _storage._adminDelete ?? SignalServiceProtos_DataMessage.AdminDelete()}
set {_uniqueStorage()._adminDelete = newValue}
}
/// Returns true if `adminDelete` has been explicitly set.
var hasAdminDelete: Bool {return _storage._adminDelete != nil}
/// Clears the value of `adminDelete`. Subsequent reads from it will return its default value.
mutating func clearAdminDelete() {_uniqueStorage()._adminDelete = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
enum Flags: Int, SwiftProtobuf.Enum, Swift.CaseIterable {
@ -2287,6 +2296,38 @@ struct SignalServiceProtos_DataMessage: @unchecked Sendable {
fileprivate var _targetSentTimestamp: UInt64? = nil
}
struct AdminDelete: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
/// 16-byte UUID
var targetAuthorAciBinary: Data {
get {return _targetAuthorAciBinary ?? Data()}
set {_targetAuthorAciBinary = newValue}
}
/// Returns true if `targetAuthorAciBinary` has been explicitly set.
var hasTargetAuthorAciBinary: Bool {return self._targetAuthorAciBinary != nil}
/// Clears the value of `targetAuthorAciBinary`. Subsequent reads from it will return its default value.
mutating func clearTargetAuthorAciBinary() {self._targetAuthorAciBinary = nil}
var targetSentTimestamp: UInt64 {
get {return _targetSentTimestamp ?? 0}
set {_targetSentTimestamp = newValue}
}
/// Returns true if `targetSentTimestamp` has been explicitly set.
var hasTargetSentTimestamp: Bool {return self._targetSentTimestamp != nil}
/// Clears the value of `targetSentTimestamp`. Subsequent reads from it will return its default value.
mutating func clearTargetSentTimestamp() {self._targetSentTimestamp = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
fileprivate var _targetAuthorAciBinary: Data? = nil
fileprivate var _targetSentTimestamp: UInt64? = nil
}
init() {}
fileprivate var _storage = _StorageClass.defaultInstance
@ -5607,7 +5648,7 @@ extension SignalServiceProtos_CallMessage.Opaque.Urgency: SwiftProtobuf._ProtoNa
extension SignalServiceProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".DataMessage"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}body\0\u{1}attachments\0\u{2}\u{2}flags\0\u{1}expireTimer\0\u{1}profileKey\0\u{1}timestamp\0\u{1}quote\0\u{1}contact\0\u{1}preview\0\u{1}sticker\0\u{1}requiredProtocolVersion\0\u{2}\u{2}isViewOnce\0\u{1}groupV2\0\u{1}reaction\0\u{1}delete\0\u{1}bodyRanges\0\u{1}groupCallUpdate\0\u{1}payment\0\u{1}storyContext\0\u{1}giftBadge\0\u{1}expireTimerVersion\0\u{1}pollCreate\0\u{1}pollTerminate\0\u{1}pollVote\0\u{1}pinMessage\0\u{1}unpinMessage\0\u{c}\u{3}\u{1}")
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}body\0\u{1}attachments\0\u{2}\u{2}flags\0\u{1}expireTimer\0\u{1}profileKey\0\u{1}timestamp\0\u{1}quote\0\u{1}contact\0\u{1}preview\0\u{1}sticker\0\u{1}requiredProtocolVersion\0\u{2}\u{2}isViewOnce\0\u{1}groupV2\0\u{1}reaction\0\u{1}delete\0\u{1}bodyRanges\0\u{1}groupCallUpdate\0\u{1}payment\0\u{1}storyContext\0\u{1}giftBadge\0\u{1}expireTimerVersion\0\u{1}pollCreate\0\u{1}pollTerminate\0\u{1}pollVote\0\u{1}pinMessage\0\u{1}unpinMessage\0\u{1}adminDelete\0\u{c}\u{3}\u{1}")
fileprivate class _StorageClass {
var _body: String? = nil
@ -5636,6 +5677,7 @@ extension SignalServiceProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf.
var _pollVote: SignalServiceProtos_DataMessage.PollVote? = nil
var _pinMessage: SignalServiceProtos_DataMessage.PinMessage? = nil
var _unpinMessage: SignalServiceProtos_DataMessage.UnpinMessage? = nil
var _adminDelete: SignalServiceProtos_DataMessage.AdminDelete? = nil
// This property is used as the initial default value for new instances of the type.
// The type itself is protecting the reference to its storage via CoW semantics.
@ -5672,6 +5714,7 @@ extension SignalServiceProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf.
_pollVote = source._pollVote
_pinMessage = source._pinMessage
_unpinMessage = source._unpinMessage
_adminDelete = source._adminDelete
}
}
@ -5716,6 +5759,7 @@ extension SignalServiceProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf.
case 26: try { try decoder.decodeSingularMessageField(value: &_storage._pollVote) }()
case 27: try { try decoder.decodeSingularMessageField(value: &_storage._pinMessage) }()
case 28: try { try decoder.decodeSingularMessageField(value: &_storage._unpinMessage) }()
case 29: try { try decoder.decodeSingularMessageField(value: &_storage._adminDelete) }()
default: break
}
}
@ -5806,6 +5850,9 @@ extension SignalServiceProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf.
try { if let v = _storage._unpinMessage {
try visitor.visitSingularMessageField(value: v, fieldNumber: 28)
} }()
try { if let v = _storage._adminDelete {
try visitor.visitSingularMessageField(value: v, fieldNumber: 29)
} }()
}
try unknownFields.traverse(visitor: &visitor)
}
@ -5841,6 +5888,7 @@ extension SignalServiceProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf.
if _storage._pollVote != rhs_storage._pollVote {return false}
if _storage._pinMessage != rhs_storage._pinMessage {return false}
if _storage._unpinMessage != rhs_storage._unpinMessage {return false}
if _storage._adminDelete != rhs_storage._adminDelete {return false}
return true
}
if !storagesAreEqual {return false}
@ -7105,6 +7153,45 @@ extension SignalServiceProtos_DataMessage.UnpinMessage: SwiftProtobuf.Message, S
}
}
extension SignalServiceProtos_DataMessage.AdminDelete: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = SignalServiceProtos_DataMessage.protoMessageName + ".AdminDelete"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}targetAuthorAciBinary\0\u{1}targetSentTimestamp\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularBytesField(value: &self._targetAuthorAciBinary) }()
case 2: try { try decoder.decodeSingularUInt64Field(value: &self._targetSentTimestamp) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._targetAuthorAciBinary {
try visitor.visitSingularBytesField(value: v, fieldNumber: 1)
} }()
try { if let v = self._targetSentTimestamp {
try visitor.visitSingularUInt64Field(value: v, fieldNumber: 2)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: SignalServiceProtos_DataMessage.AdminDelete, rhs: SignalServiceProtos_DataMessage.AdminDelete) -> Bool {
if lhs._targetAuthorAciBinary != rhs._targetAuthorAciBinary {return false}
if lhs._targetSentTimestamp != rhs._targetSentTimestamp {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension SignalServiceProtos_NullMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".NullMessage"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}padding\0")

View File

@ -430,6 +430,11 @@ message DataMessage {
optional uint64 targetSentTimestamp = 2;
}
message AdminDelete {
optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
optional uint64 targetSentTimestamp = 2;
}
optional string body = 1;
repeated AttachmentPointer attachments = 2;
reserved /*groupV1Context*/ 3;
@ -457,7 +462,8 @@ message DataMessage {
optional PollVote pollVote = 26;
optional PinMessage pinMessage = 27;
optional UnpinMessage unpinMessage = 28;
// NEXT ID: 29
optional AdminDelete adminDelete = 29;
// NEXT ID: 30
}
message NullMessage {

View File

@ -43,7 +43,7 @@ class TSMessageTest: SSKBaseTest {
XCTAssertEqual(now + UInt64(expirationSeconds * 1000), message.expiresAt)
}
func testCanBeRemotelyDeleted() {
func canBeRemotelyDeletedByNonAdmin() {
let now = Date.ows_millisecondTimestamp()
do {
@ -51,7 +51,7 @@ class TSMessageTest: SSKBaseTest {
builder.timestamp = now - UInt64.minuteInMs
let message = SSKEnvironment.shared.databaseStorageRef.read { builder.build(transaction: $0) }
XCTAssert(message.canBeRemotelyDeleted)
XCTAssert(message.canBeRemotelyDeletedByNonAdmin)
}
do {
@ -59,7 +59,7 @@ class TSMessageTest: SSKBaseTest {
builder.timestamp = now - UInt64.minuteInMs
let message = builder.build()
XCTAssertFalse(message.canBeRemotelyDeleted)
XCTAssertFalse(message.canBeRemotelyDeletedByNonAdmin)
}
do {
@ -71,7 +71,7 @@ class TSMessageTest: SSKBaseTest {
message.updateWithRemotelyDeletedAndRemoveRenderableContent(with: transaction)
}
XCTAssertFalse(message.canBeRemotelyDeleted)
XCTAssertFalse(message.canBeRemotelyDeletedByNonAdmin)
}
do {
@ -80,7 +80,7 @@ class TSMessageTest: SSKBaseTest {
builder.giftBadge = OWSGiftBadge(redemptionCredential: Data())
let message = SSKEnvironment.shared.databaseStorageRef.read { builder.build(transaction: $0) }
XCTAssertFalse(message.canBeRemotelyDeleted)
XCTAssertFalse(message.canBeRemotelyDeletedByNonAdmin)
}
do {
@ -88,7 +88,7 @@ class TSMessageTest: SSKBaseTest {
builder.timestamp = now + UInt64.minuteInMs
let message = SSKEnvironment.shared.databaseStorageRef.read { builder.build(transaction: $0) }
XCTAssertTrue(message.canBeRemotelyDeleted)
XCTAssertTrue(message.canBeRemotelyDeletedByNonAdmin)
}
do {
@ -96,7 +96,7 @@ class TSMessageTest: SSKBaseTest {
builder.timestamp = now + (25 * UInt64.hourInMs)
let message = SSKEnvironment.shared.databaseStorageRef.read { builder.build(transaction: $0) }
XCTAssertTrue(message.canBeRemotelyDeleted)
XCTAssertTrue(message.canBeRemotelyDeletedByNonAdmin)
}
do {
@ -104,7 +104,7 @@ class TSMessageTest: SSKBaseTest {
builder.timestamp = now - (25 * UInt64.hourInMs)
let message = SSKEnvironment.shared.databaseStorageRef.read { builder.build(transaction: $0) }
XCTAssertFalse(message.canBeRemotelyDeleted)
XCTAssertFalse(message.canBeRemotelyDeletedByNonAdmin)
}
}
}