573 lines
25 KiB
Swift
573 lines
25 KiB
Swift
//
|
||
// Copyright 2024 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
import LibSignalClient
|
||
import XCTest
|
||
|
||
@testable import SignalServiceKit
|
||
|
||
class MessageBackupIntegrationTests: XCTestCase {
|
||
override func setUp() {
|
||
/// By default, we cap test runs to 60s in CI. This test might run
|
||
/// longer, since we have very many integration test cases, so this
|
||
/// extends the time allowance specifically for this test.
|
||
///
|
||
/// As an alternative, we could avoid running all the integration test
|
||
/// cases in one giant test. For example, in Swift Testing we can
|
||
/// "parameterize" tests, for example parameterizing over the list of
|
||
/// integration test cases, such that there's one "test" per integration
|
||
/// test case. I tried that, but unfortunately at the time of writing
|
||
/// Xcode struggles mightily with highly parameterized tests; the tests
|
||
/// ran very slowly, and Xcode itself beach-balled.
|
||
///
|
||
/// In the future, if Xcode supports this better, we can move this test
|
||
/// to use Swift Testing and parameterization.
|
||
///
|
||
/// - SeeAlso
|
||
/// The `-test-timeouts-enabled`, `-default-test-execution-time-allowance`,
|
||
/// and `-default-test-execution-time-allowance` command-line arguments
|
||
/// passed during CI.
|
||
executionTimeAllowance = 300
|
||
|
||
DDLog.add(DDTTYLogger.sharedInstance!)
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
/// Describes what output to log if LibSignal reports a test failure.
|
||
private enum LibSignalComparisonFailureLogOutput {
|
||
/// Log the full backup JSONs returned by LibSignal, for external
|
||
/// analysis via `parse-libsignal-comparator-failure.py`.
|
||
case fullLibSignalJSON
|
||
|
||
/// Log a minimal diff of the backup JSONs returned by LibSignal, for
|
||
/// inline analysis in the Xcode logs.
|
||
case minimalDiff
|
||
}
|
||
|
||
private enum WhichIntegrationTestCases {
|
||
case all
|
||
case specific(names: Set<String>)
|
||
|
||
case standardFrames
|
||
|
||
case accountData
|
||
|
||
case adHocCall
|
||
|
||
case chat
|
||
|
||
case chatItem
|
||
case chatItemContactMessage
|
||
case chatItemExpirationTimerUpdate
|
||
case chatItemGiftBadge
|
||
case chatItemGroupCall
|
||
case chatItemGroupChangeChatUpdate
|
||
case chatItemGroupChangeChatMultipleUpdate
|
||
case chatItemIndividualCall
|
||
case chatItemLearnedProfile
|
||
case chatItemPaymentNotification
|
||
case chatItemProfileChange
|
||
case chatItemRemoteDeleteTombstone
|
||
case chatItemSessionSwitchover
|
||
case chatItemSimpleUpdates
|
||
case chatItemStandardMessageFormatted
|
||
case chatItemStandardMessageLinkPreview
|
||
case chatItemStandardMessageLongText
|
||
case chatItemStandardMessageSms
|
||
case chatItemStandardMessageSpecialAttachments
|
||
case chatItemStandardMessageStandardAttachments
|
||
case chatItemStandardMessageTextOnly
|
||
case chatItemStandardMessageWithEdits
|
||
case chatItemStandardMessageWithQuote
|
||
case chatItemStickerMessage
|
||
case chatItemThreadMerge
|
||
case chatItemViewOnceMessage
|
||
|
||
case recipient
|
||
case recipientCallLink
|
||
case recipientContact
|
||
case recipientDistributionList
|
||
case recipientGroup
|
||
|
||
case stickerPack
|
||
}
|
||
|
||
/// The preferred log output for test failures.
|
||
///
|
||
/// Set by default to `.minimalDiff` to reduce log noise in automated test
|
||
/// runs. Toggle to `.fullLibSignalJSON` if desired during local
|
||
/// development, for more thorough inspection of the failure case.
|
||
private let preferredFailureLogOutput: LibSignalComparisonFailureLogOutput = .minimalDiff
|
||
|
||
/// Specifies which integration test cases to run.
|
||
///
|
||
/// Set by default to `.all`. May be toggled to a subset of tests during
|
||
/// local development for debugging purposes, but should never be committed
|
||
/// to `main` as anything other than `.all`.
|
||
private let whichIntegrationTestCases: WhichIntegrationTestCases = .all
|
||
|
||
// MARK: -
|
||
|
||
/// Performs a round-trip import/export test on all `.binproto` integration
|
||
/// test cases.
|
||
func testIntegrationTestCases() async throws {
|
||
let binProtoFileUrls: [URL] = {
|
||
let allBinprotoUrls = Bundle(for: type(of: self)).urls(
|
||
forResourcesWithExtension: "binproto",
|
||
subdirectory: nil
|
||
) ?? []
|
||
|
||
return allBinprotoUrls.filter { binprotoUrl in
|
||
let binprotoName = binprotoUrl
|
||
.lastPathComponent
|
||
.filenameWithoutExtension
|
||
|
||
switch whichIntegrationTestCases {
|
||
case .all:
|
||
return true
|
||
case .specific(let names):
|
||
return names.contains(binprotoName)
|
||
case .standardFrames:
|
||
return binprotoName.contains("standard_frames")
|
||
case .accountData:
|
||
return binprotoName.contains("account_data_")
|
||
case .adHocCall:
|
||
return binprotoName.contains("ad_hoc_call_")
|
||
case .chat:
|
||
return binprotoName.contains("chat_")
|
||
case .chatItem:
|
||
return binprotoName.contains("chat_item_")
|
||
case .chatItemContactMessage:
|
||
return binprotoName.contains("chat_item_contact_message_")
|
||
case .chatItemExpirationTimerUpdate:
|
||
return binprotoName.contains("chat_item_expiration_timer_update_")
|
||
case .chatItemGiftBadge:
|
||
return binprotoName.contains("chat_item_gift_badge_")
|
||
case .chatItemGroupCall:
|
||
return binprotoName.contains("chat_item_group_call_update_")
|
||
case .chatItemGroupChangeChatUpdate:
|
||
return binprotoName.contains("chat_item_group_change_chat_update_")
|
||
case .chatItemGroupChangeChatMultipleUpdate:
|
||
return binprotoName.contains("chat_item_group_change_chat_multiple_update_")
|
||
case .chatItemIndividualCall:
|
||
return binprotoName.contains("chat_item_individual_call_update_")
|
||
case .chatItemLearnedProfile:
|
||
return binprotoName.contains("chat_item_learned_profile_update_")
|
||
case .chatItemPaymentNotification:
|
||
return binprotoName.contains("chat_item_payment_notification_")
|
||
case .chatItemProfileChange:
|
||
return binprotoName.contains("chat_item_profile_change_")
|
||
case .chatItemRemoteDeleteTombstone:
|
||
return binprotoName.contains("chat_item_remote_delete_")
|
||
case .chatItemSessionSwitchover:
|
||
return binprotoName.contains("chat_item_session_switchover_update_")
|
||
case .chatItemSimpleUpdates:
|
||
return binprotoName.contains("chat_item_simple_updates_")
|
||
case .chatItemStandardMessageFormatted:
|
||
return binprotoName.contains("chat_item_standard_message_formatted_")
|
||
case .chatItemStandardMessageLinkPreview:
|
||
return binprotoName.contains("chat_item_standard_message_with_link_preview_")
|
||
case .chatItemStandardMessageLongText:
|
||
return binprotoName.contains("chat_item_standard_message_long_text_")
|
||
case .chatItemStandardMessageSms:
|
||
return binprotoName.contains("chat_item_standard_message_sms_")
|
||
case .chatItemStandardMessageSpecialAttachments:
|
||
return binprotoName.contains("chat_item_standard_message_special_attachments_")
|
||
case .chatItemStandardMessageStandardAttachments:
|
||
return binprotoName.contains("chat_item_standard_message_standard_attachments_")
|
||
case .chatItemStandardMessageTextOnly:
|
||
return binprotoName.contains("chat_item_standard_message_text_only_")
|
||
case .chatItemStandardMessageWithEdits:
|
||
return binprotoName.contains("chat_item_standard_message_with_edits_")
|
||
case .chatItemStandardMessageWithQuote:
|
||
return binprotoName.contains("chat_item_standard_message_with_quote_")
|
||
case .chatItemStickerMessage:
|
||
return binprotoName.contains("chat_item_sticker_message_")
|
||
case .chatItemThreadMerge:
|
||
return binprotoName.contains("chat_item_thread_merge_")
|
||
case .chatItemViewOnceMessage:
|
||
return binprotoName.contains("chat_item_view_once_")
|
||
case .recipient:
|
||
return binprotoName.contains("recipient_")
|
||
case .recipientCallLink:
|
||
return binprotoName.contains("recipient_call_link_")
|
||
case .recipientContact:
|
||
return binprotoName.contains("recipient_contacts_")
|
||
case .recipientDistributionList:
|
||
return binprotoName.contains("recipient_distribution_list_")
|
||
case .recipientGroup:
|
||
return binprotoName.contains("recipient_groups_")
|
||
case .stickerPack:
|
||
return binprotoName.contains("sticker_pack_")
|
||
}
|
||
}
|
||
}()
|
||
|
||
guard binProtoFileUrls.count > 0 else {
|
||
XCTFail("Failed to find binprotos in test bundle!")
|
||
return
|
||
}
|
||
|
||
for binprotoFileUrl in binProtoFileUrls {
|
||
let filename = binprotoFileUrl
|
||
.lastPathComponent
|
||
.filenameWithoutExtension
|
||
|
||
/// Separate the `Logger` and `XCTFail` steps. We want the test to
|
||
/// fail, but `XCTFail` is slow to get its output into the console,
|
||
/// so we'll log the interesting failure message separately so it's
|
||
/// sequential with whatever else is being logged (such as the next
|
||
/// test starting).
|
||
func logFailure(_ message: String) {
|
||
Logger.error(message)
|
||
XCTFail(filename)
|
||
}
|
||
|
||
do {
|
||
Logger.info("""
|
||
|
||
|
||
[TestCase] Running test case: \(filename)
|
||
|
||
""")
|
||
|
||
try await runRoundTripTest(
|
||
testCaseFileUrl: binprotoFileUrl,
|
||
failureLogOutput: preferredFailureLogOutput
|
||
)
|
||
} catch TestError.failure(let message) {
|
||
logFailure("""
|
||
|
||
------------
|
||
|
||
Test case failed: \(filename)!
|
||
|
||
\(message)
|
||
|
||
------------
|
||
""")
|
||
} catch let error {
|
||
logFailure("""
|
||
|
||
------------
|
||
|
||
Test case failed with unexpected error: \(filename)!
|
||
|
||
\(error)
|
||
|
||
------------
|
||
""")
|
||
}
|
||
}
|
||
|
||
/// Ensure we write all log output before the test finishes.
|
||
Logger.flush()
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private enum TestError: Error {
|
||
case failure(String)
|
||
}
|
||
|
||
private var deps: DependenciesBridge { .shared }
|
||
|
||
/// Runs a round-trip import/export test for the given `.binproto` file.
|
||
///
|
||
/// The round-trip test imports the given `.binproto` into an empty app,
|
||
/// then exports the app's state into another `.binproto`. The
|
||
/// originally-imported and recently-exported `.binprotos` are then compared
|
||
/// by LibSignal. They should be equivalent; any disparity indicates that
|
||
/// some data was dropped or modified as part of the import/export process,
|
||
/// which should be idempotent.
|
||
private func runRoundTripTest(
|
||
testCaseFileUrl: URL,
|
||
failureLogOutput: LibSignalComparisonFailureLogOutput
|
||
) async throws {
|
||
/// A backup doesn't contain our own local identifiers. Rather, those
|
||
/// are determined as part of registration for a backup import, and are
|
||
/// already-known for a backup export.
|
||
///
|
||
/// Consequently, we can use any local identifiers for our test
|
||
/// purposes without worrying about the contents of each test case's
|
||
/// backup file.
|
||
let localIdentifiers: LocalIdentifiers = .forUnitTests
|
||
|
||
/// Backup files hardcode timestamps, some of which are interpreted
|
||
/// relative to "now". For example, "deleted" story distribution lists
|
||
/// are marked as deleted for a period of time before being actually
|
||
/// deleted; when these frames are restored from a Backup, their
|
||
/// deletion timestamp is compared to "now" to determine if they should
|
||
/// be deleted.
|
||
///
|
||
/// Consequently, in order for tests to remain stable over time we need
|
||
/// to "anchor" them with an unchanging timestamp. To that end, we'll
|
||
/// extract the `backupTimeMs` field from the Backup header, and use
|
||
/// that as our "now" during import.
|
||
let backupTimeMs = try await readBackupTimeMs(testCaseFileUrl: testCaseFileUrl)
|
||
|
||
await initializeApp(dateProvider: { Date(millisecondsSince1970: backupTimeMs) })
|
||
|
||
try await deps.messageBackupManager.importPlaintextBackup(
|
||
fileUrl: testCaseFileUrl,
|
||
localIdentifiers: localIdentifiers,
|
||
progress: nil
|
||
)
|
||
|
||
let exportedBackupUrl = try await deps.messageBackupManager
|
||
.exportPlaintextBackup(localIdentifiers: localIdentifiers, backupPurpose: .remoteBackup, progress: nil)
|
||
|
||
try compareViaLibsignal(
|
||
sharedTestCaseBackupUrl: testCaseFileUrl,
|
||
exportedBackupUrl: exportedBackupUrl,
|
||
failureLogOutput: failureLogOutput
|
||
)
|
||
}
|
||
|
||
/// Compare the canonical representation of the Backups at the two given
|
||
/// file URLs, via `LibSignal`.
|
||
///
|
||
/// - Throws
|
||
/// If there are errors reading or validating either Backup, or if the
|
||
/// Backups' canonical representations are not equal.
|
||
private func compareViaLibsignal(
|
||
sharedTestCaseBackupUrl: URL,
|
||
exportedBackupUrl: URL,
|
||
failureLogOutput: LibSignalComparisonFailureLogOutput
|
||
) throws {
|
||
#if targetEnvironment(simulator)
|
||
let sharedTestCaseBackup = try ComparableBackup(url: sharedTestCaseBackupUrl)
|
||
let exportedBackup = try ComparableBackup(url: exportedBackupUrl)
|
||
|
||
guard sharedTestCaseBackup.unknownFields.fields.isEmpty else {
|
||
throw TestError.failure("Unknown fields: \(sharedTestCaseBackup.unknownFields)!")
|
||
}
|
||
|
||
let sharedTestCaseBackupString = sharedTestCaseBackup.comparableString()
|
||
let exportedBackupString = exportedBackup.comparableString()
|
||
|
||
let jsonEncoder = JSONEncoder()
|
||
jsonEncoder.outputFormatting = .prettyPrinted
|
||
|
||
if sharedTestCaseBackupString != exportedBackupString {
|
||
switch failureLogOutput {
|
||
case .fullLibSignalJSON:
|
||
throw TestError.failure("""
|
||
Copy the JSON lines below and run `pbpaste | parse-libsignal-comparator-failure.py`.
|
||
|
||
\(sharedTestCaseBackupString.removeCharacters(characterSet: .whitespacesAndNewlines))
|
||
\(exportedBackupString.removeCharacters(characterSet: .whitespacesAndNewlines))
|
||
""")
|
||
case .minimalDiff:
|
||
let jsonStringDiff: LineByLineStringDiff = .diffing(
|
||
lhs: sharedTestCaseBackupString,
|
||
rhs: exportedBackupString
|
||
)
|
||
|
||
let prettyDiff = jsonStringDiff.prettyPrint(
|
||
lhsLabel: "testcase",
|
||
rhsLabel: "exported",
|
||
diffGroupDivider: "************"
|
||
)
|
||
|
||
throw TestError.failure("""
|
||
JSON diff:
|
||
|
||
\(prettyDiff)
|
||
""")
|
||
}
|
||
}
|
||
#else
|
||
throw XCTSkip("LibSignalClient.ComparableBackup is only available in the simulator.")
|
||
#endif
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
/// Read the `backupTimeMs` field from the header of the Backup file at the
|
||
/// given local URL.
|
||
private func readBackupTimeMs(testCaseFileUrl: URL) async throws -> UInt64 {
|
||
let plaintextStreamProvider = MessageBackupPlaintextProtoStreamProviderImpl()
|
||
|
||
let progress = try await MessageBackupImportProgress.prepare(sink: nil, fileUrl: testCaseFileUrl)
|
||
|
||
class FakeMemorySampler: MemorySampler {
|
||
init() {}
|
||
func sample() {}
|
||
}
|
||
|
||
let stream: MessageBackupProtoInputStream
|
||
switch plaintextStreamProvider.openPlaintextInputFileStream(
|
||
fileUrl: testCaseFileUrl,
|
||
progress: progress,
|
||
memorySampler: FakeMemorySampler()
|
||
) {
|
||
case .success(let _stream, _):
|
||
stream = _stream
|
||
case .fileNotFound:
|
||
throw TestError.failure("Missing test case backup file!")
|
||
case .unableToOpenFileStream:
|
||
throw TestError.failure("Failed to open test case backup file!")
|
||
case .hmacValidationFailedOnEncryptedFile:
|
||
throw TestError.failure("Impossible – this is a plaintext stream!")
|
||
}
|
||
|
||
let backupInfo: BackupProto_BackupInfo
|
||
switch stream.readHeader() {
|
||
case .success(let _backupInfo, _):
|
||
backupInfo = _backupInfo
|
||
case .invalidByteLengthDelimiter:
|
||
throw TestError.failure("Invalid byte length delimiter!")
|
||
case .emptyFinalFrame:
|
||
throw TestError.failure("Invalid empty header frame!")
|
||
case .protoDeserializationError(let error):
|
||
throw TestError.failure("Proto deserialization error: \(error)!")
|
||
}
|
||
|
||
return backupInfo.backupTimeMs
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
@MainActor
|
||
private func initializeApp(dateProvider: DateProvider?) async {
|
||
let testAppContext = TestAppContext()
|
||
SetCurrentAppContext(testAppContext)
|
||
let appReadiness = AppReadinessMock()
|
||
|
||
/// Note that ``SDSDatabaseStorage/grdbDatabaseFileUrl``, through a few
|
||
/// layers of abstraction, uses the "current app context" to decide
|
||
/// where to put the database,
|
||
///
|
||
/// For a ``TestAppContext`` as configured above, this will be a
|
||
/// subdirectory of our temp directory unique to the instantiation of
|
||
/// the app context.
|
||
let databaseStorage = try! SDSDatabaseStorage(
|
||
appReadiness: appReadiness,
|
||
databaseFileUrl: SDSDatabaseStorage.grdbDatabaseFileUrl,
|
||
keychainStorage: MockKeychainStorage()
|
||
)
|
||
|
||
let svrKeyDeriver = SVRKeyDeriverMock()
|
||
let backupKey = Data(repeating: 8, count: 32)
|
||
svrKeyDeriver.dataGenerator = { derivedKey in
|
||
switch derivedKey {
|
||
case .backupKey:
|
||
return backupKey
|
||
default:
|
||
owsFail("Unexpected key request")
|
||
}
|
||
}
|
||
|
||
/// We use crashy versions of dependencies that should never be called
|
||
/// during backups, and no-op implementations of payments because those
|
||
/// are bound to the SignalUI target.
|
||
_ = await AppSetup().start(
|
||
appContext: testAppContext,
|
||
appReadiness: appReadiness,
|
||
databaseStorage: databaseStorage,
|
||
paymentsEvents: PaymentsEventsNoop(),
|
||
mobileCoinHelper: MobileCoinHelperMock(),
|
||
callMessageHandler: CrashyMocks.MockCallMessageHandler(),
|
||
currentCallProvider: CrashyMocks.MockCurrentCallThreadProvider(),
|
||
notificationPresenter: CrashyMocks.MockNotificationPresenter(),
|
||
incrementalMessageTSAttachmentMigratorFactory: NoOpIncrementalMessageTSAttachmentMigratorFactory(),
|
||
messageBackupErrorPresenterFactory: NoOpMessageBackupErrorPresenterFactory(),
|
||
testDependencies: AppSetup.TestDependencies(
|
||
backupAttachmentDownloadManager: BackupAttachmentDownloadManagerMock(),
|
||
dateProvider: dateProvider,
|
||
networkManager: CrashyMocks.MockNetworkManager(libsignalNet: nil),
|
||
svrKeyDeriver: svrKeyDeriver,
|
||
webSocketFactory: CrashyMocks.MockWebSocketFactory()
|
||
)
|
||
).prepareDatabase().awaitable()
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
#if targetEnvironment(simulator)
|
||
private extension LibSignalClient.ComparableBackup {
|
||
convenience init(url: URL) throws {
|
||
let fileHandle = try FileHandle(forReadingFrom: url)
|
||
let fileLength = try fileHandle.seekToEnd()
|
||
try fileHandle.seek(toOffset: 0)
|
||
|
||
try self.init(
|
||
purpose: .remoteBackup,
|
||
length: fileLength,
|
||
stream: fileHandle
|
||
)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: - CrashyMocks
|
||
|
||
private func failTest<T>(
|
||
_ type: T.Type,
|
||
_ function: StaticString = #function
|
||
) -> Never {
|
||
let message = "Unexpectedly called \(type)#\(function)!"
|
||
XCTFail(message)
|
||
owsFail(message)
|
||
}
|
||
|
||
/// As a rule, integration tests for message backup should not mock out their
|
||
/// dependencies as their goal is to validate how the real, production app will
|
||
/// behave with respect to Backups.
|
||
///
|
||
/// These mocks are the exceptions to that rule, and encompass managers that
|
||
/// should never be invoked during Backup import or export.
|
||
private enum CrashyMocks {
|
||
final class MockNetworkManager: NetworkManager {
|
||
override func asyncRequest(_ request: TSRequest, canUseWebSocket: Bool = false) async throws -> any HTTPResponse { failTest(Self.self) }
|
||
override func makePromise(request: TSRequest, canUseWebSocket: Bool = false) -> Promise<any HTTPResponse> { failTest(Self.self) }
|
||
}
|
||
|
||
final class MockWebSocketFactory: WebSocketFactory {
|
||
var canBuildWebSocket: Bool { failTest(Self.self) }
|
||
func buildSocket(request: WebSocketRequest, callbackScheduler: any Scheduler) -> (any SSKWebSocket)? { failTest(Self.self) }
|
||
}
|
||
|
||
final class MockCallMessageHandler: CallMessageHandler {
|
||
func receivedEnvelope(_ envelope: SSKProtoEnvelope, callEnvelope: CallEnvelopeType, from caller: (aci: Aci, deviceId: UInt32), toLocalIdentity localIdentity: OWSIdentity, plaintextData: Data, wasReceivedByUD: Bool, sentAtTimestamp: UInt64, serverReceivedTimestamp: UInt64, serverDeliveryTimestamp: UInt64, tx: SDSAnyWriteTransaction) { failTest(Self.self) }
|
||
func receivedGroupCallUpdateMessage(_ updateMessage: SSKProtoDataMessageGroupCallUpdate, forGroupId groupId: GroupIdentifier, serverReceivedTimestamp: UInt64) async { failTest(Self.self) }
|
||
}
|
||
|
||
final class MockCurrentCallThreadProvider: CurrentCallProvider {
|
||
var hasCurrentCall: Bool { failTest(Self.self) }
|
||
var currentGroupThreadCallGroupId: GroupIdentifier? { failTest(Self.self) }
|
||
}
|
||
|
||
final class MockNotificationPresenter: NotificationPresenter {
|
||
func registerNotificationSettings() async { failTest(Self.self) }
|
||
func notifyUser(forIncomingMessage: TSIncomingMessage, thread: TSThread, transaction: SDSAnyReadTransaction) { failTest(Self.self) }
|
||
func notifyUser(forIncomingMessage: TSIncomingMessage, editTarget: TSIncomingMessage, thread: TSThread, transaction: SDSAnyReadTransaction) { failTest(Self.self) }
|
||
func notifyUser(forReaction: OWSReaction, onOutgoingMessage: TSOutgoingMessage, thread: TSThread, transaction: SDSAnyReadTransaction) { failTest(Self.self) }
|
||
func notifyUser(forErrorMessage: TSErrorMessage, thread: TSThread, transaction: SDSAnyWriteTransaction) { failTest(Self.self) }
|
||
func notifyUser(forTSMessage: TSMessage, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction) { failTest(Self.self) }
|
||
func notifyUser(forPreviewableInteraction: any TSInteraction & OWSPreviewText, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction) { failTest(Self.self) }
|
||
func notifyTestPopulation(ofErrorMessage errorString: String) { failTest(Self.self) }
|
||
func notifyUser(forFailedStorySend: StoryMessage, to: TSThread, transaction: SDSAnyWriteTransaction) { failTest(Self.self) }
|
||
func notifyUserOfFailedSend(inThread thread: TSThread) { failTest(Self.self) }
|
||
func notifyUserOfMissedCall(notificationInfo: CallNotificationInfo, offerMediaType: TSRecentCallOfferType, sentAt timestamp: Date, tx: SDSAnyReadTransaction) { failTest(Self.self) }
|
||
func notifyUserOfMissedCallBecauseOfNewIdentity(notificationInfo: CallNotificationInfo, tx: SDSAnyReadTransaction) { failTest(Self.self) }
|
||
func notifyUserOfMissedCallBecauseOfNoLongerVerifiedIdentity(notificationInfo: CallNotificationInfo, tx: SDSAnyReadTransaction) { failTest(Self.self) }
|
||
func notifyForGroupCallSafetyNumberChange(callTitle: String, threadUniqueId: String?, roomId: Data?, presentAtJoin: Bool) { failTest(Self.self) }
|
||
func scheduleNotifyForNewLinkedDevice() { failTest(Self.self) }
|
||
func notifyUserToRelaunchAfterTransfer(completion: @escaping () -> Void) { failTest(Self.self) }
|
||
func notifyUserOfDeregistration(tx: any DBWriteTransaction) { failTest(Self.self) }
|
||
func clearAllNotifications() { failTest(Self.self) }
|
||
func clearAllNotificationsExceptNewLinkedDevices() { failTest(Self.self) }
|
||
static func clearAllNotificationsExceptNewLinkedDevices() { failTest(Self.self) }
|
||
func cancelNotifications(threadId: String) { failTest(Self.self) }
|
||
func cancelNotifications(messageIds: [String]) { failTest(Self.self) }
|
||
func cancelNotifications(reactionId: String) { failTest(Self.self) }
|
||
func cancelNotificationsForMissedCalls(threadUniqueId: String) { failTest(Self.self) }
|
||
func cancelNotifications(for storyMessage: StoryMessage) { failTest(Self.self) }
|
||
}
|
||
}
|