mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 03:25:20 +00:00
Retry confirmed private media after reconnect
This commit is contained in:
@@ -95,10 +95,20 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
transferId: String
|
||||
)] = []
|
||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var privateFileReceiptRetryTransferIDs: [String] = []
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
|
||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
var resolvesPrivateMediaPolicyImmediately = true
|
||||
var supportsAuthenticatedPrivateMediaReceipts = false
|
||||
var authenticatedPrivateMediaReceiptGeneration = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000001"
|
||||
)!
|
||||
private var pendingPrivateMediaPolicyResolutions: [
|
||||
@MainActor (PrivateMediaSendPolicy) -> Void
|
||||
] = []
|
||||
private(set) var legacyConsentRequests: [(
|
||||
id: UUID,
|
||||
peerID: PeerID,
|
||||
@@ -113,11 +123,40 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
privateMediaPolicy
|
||||
}
|
||||
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID? {
|
||||
supportsAuthenticatedPrivateMediaReceipts
|
||||
? authenticatedPrivateMediaReceiptGeneration
|
||||
: nil
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
privateMediaPolicyResolutionRequests.append(peerID)
|
||||
if resolvesPrivateMediaPolicyImmediately {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
} else {
|
||||
pendingPrivateMediaPolicyResolutions.append(completion)
|
||||
}
|
||||
}
|
||||
|
||||
var pendingPrivateMediaPolicyResolutionCount: Int {
|
||||
pendingPrivateMediaPolicyResolutions.count
|
||||
}
|
||||
|
||||
func resolveNextPrivateMediaPolicy(
|
||||
_ policy: PrivateMediaSendPolicy? = nil
|
||||
) {
|
||||
guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return }
|
||||
let completion = pendingPrivateMediaPolicyResolutions.removeFirst()
|
||||
completion(
|
||||
policy
|
||||
?? resolvedPrivateMediaPolicy
|
||||
?? privateMediaPolicy
|
||||
)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
@@ -162,6 +201,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
func sendFilePrivateReceiptRetry(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String
|
||||
) {
|
||||
privateFileSends.append((packet, peerID, transferId))
|
||||
privateFileLegacyAllowances.append(false)
|
||||
privateFileReceiptRetryTransferIDs.append(transferId)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
broadcastFileSends.append(transferId)
|
||||
}
|
||||
@@ -275,6 +324,59 @@ private final class PausedImagePreparer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class StaticVoiceNotePreparer: @unchecked Sendable {
|
||||
private let packet: BitchatFilePacket
|
||||
|
||||
init(fileName: String, content: Data = Data("voice".utf8)) {
|
||||
packet = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
func prepare(_ url: URL) throws -> BitchatFilePacket {
|
||||
packet
|
||||
}
|
||||
}
|
||||
|
||||
private final class DeterministicMediaTransferIDFactory:
|
||||
@unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var nextOrdinal = 0
|
||||
|
||||
func make(messageID: String) -> String {
|
||||
lock.lock()
|
||||
defer {
|
||||
nextOrdinal += 1
|
||||
lock.unlock()
|
||||
}
|
||||
return "\(messageID)-attempt-\(nextOrdinal)"
|
||||
}
|
||||
}
|
||||
|
||||
private final class MutableMediaRetryClock: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: Date
|
||||
|
||||
init(_ value: Date) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func now() -> Date {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return value
|
||||
}
|
||||
|
||||
func advance(by interval: TimeInterval) {
|
||||
lock.lock()
|
||||
value = value.addingTimeInterval(interval)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `ChatMediaTransferCoordinator` against
|
||||
@@ -760,6 +862,548 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.legacyConsentRequests.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_0011223344556677.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
context.privateChats[peerID]?.first?.id
|
||||
)
|
||||
let initial = try #require(
|
||||
context.privateFileSends.first
|
||||
)
|
||||
#expect(PrivateMediaMessageIdentity.isStableID(messageID))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
#expect(context.deliveryStatusUpdates.last?.status == .sent)
|
||||
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileSends.count == 2)
|
||||
let retry = try #require(context.privateFileSends.last)
|
||||
#expect(retry.packet.encode() == initial.packet.encode())
|
||||
#expect(retry.peerID == peerID)
|
||||
#expect(retry.transferId != initial.transferId)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs == [
|
||||
retry.transferId
|
||||
])
|
||||
#expect(context.privateFileLegacyAllowances == [false, false])
|
||||
|
||||
coordinator.confirmPrivateMediaDelivery(messageID: messageID)
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.cancelledTransfers == [retry.transferId])
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(!context.deliveryStatusUpdates.contains {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
})
|
||||
|
||||
// Receipt confirmation removes retry ownership before transport
|
||||
// cancellation, so its late callback cannot delete the delivered row
|
||||
// or re-arm another reconnect retry.
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: retry.transferId,
|
||||
sentFragments: 1,
|
||||
totalFragments: 2
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileSends.count == 2)
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .encrypted
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = false
|
||||
let fileName = "voice_1111222233334444.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.privateMediaPolicyResolutionRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
// Even a contradictory stale bit-9 observation must not retain an
|
||||
// invocation that actually selected the explicit raw path.
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_2222333344445555.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
context.resolveNextLegacyConsent(true)
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
#expect(context.privateFileLegacyAllowances == [true])
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
context.privateMediaPolicy = .encrypted
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedGenerationSupersedesStaleReconnectResolution() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
context.resolvesPrivateMediaPolicyImmediately = false
|
||||
let oldGeneration = context
|
||||
.authenticatedPrivateMediaReceiptGeneration
|
||||
let newGeneration = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000002"
|
||||
)!
|
||||
let fileName = "voice_3333444455556666.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
|
||||
|
||||
context.authenticatedPrivateMediaReceiptGeneration = newGeneration
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 2)
|
||||
|
||||
// The old-generation completion lost ownership and is inert.
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.cancelledTransfers.isEmpty)
|
||||
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
#expect(context.cancelledTransfers == [initial.transferId])
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
|
||||
#expect(
|
||||
context.authenticatedPrivateMediaReceiptGeneration
|
||||
== newGeneration
|
||||
)
|
||||
#expect(oldGeneration != newGeneration)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
context.resolvesPrivateMediaPolicyImmediately = false
|
||||
let fileName = "voice_3333444455556666.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 1,
|
||||
maxRetainedBytes: 1_024,
|
||||
maxRetriesPerMessage: 1,
|
||||
retentionSeconds: 0.1,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
|
||||
let failedBeforePanic = context.deliveryStatusUpdates.filter {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
coordinator.resetForPanic()
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
try await Task.sleep(nanoseconds: 250_000_000)
|
||||
coordinator._test_expireReconnectRetries()
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.filter {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
}.count == failedBeforePanic)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func retryCountAndRetentionTimeEndInVisibleFailure() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let clock = MutableMediaRetryClock(
|
||||
Date(timeIntervalSince1970: 4_000)
|
||||
)
|
||||
let limits = PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 2,
|
||||
maxRetainedBytes: 1_024,
|
||||
maxRetriesPerMessage: 1,
|
||||
retentionSeconds: 10,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
let fileName = "voice_4444555566667777.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
now: clock.now,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
let retryID = try #require(
|
||||
context.privateFileReceiptRetryTransferIDs.first
|
||||
)
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: retryID,
|
||||
sentFragments: 0,
|
||||
totalFragments: 1
|
||||
))
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.contains {
|
||||
$0.messageID.hasPrefix("media-")
|
||||
&& $0.status == .failed(reason: String(
|
||||
localized: "content.delivery.reason.not_delivered",
|
||||
defaultValue: "Not delivered",
|
||||
comment: "Failure reason shown when a private media transfer could not finish"
|
||||
))
|
||||
})
|
||||
|
||||
// A separate retained row that locally completed but never received a
|
||||
// remote receipt expires to a distinct visible failure.
|
||||
let ttlFileName = "voice_5555666677778888.m4a"
|
||||
let ttlPreparer = StaticVoiceNotePreparer(
|
||||
fileName: ttlFileName
|
||||
)
|
||||
let ttlCoordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try ttlPreparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
now: clock.now,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let ttlURL = try makeCoordinatorVoiceURL(
|
||||
fileName: ttlFileName
|
||||
)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: ttlURL.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
let sendsBeforeTTL = context.privateFileSends.count
|
||||
ttlCoordinator.sendVoiceNote(at: ttlURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
context.privateFileSends.count
|
||||
== sendsBeforeTTL + 1
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let ttlInitial = try #require(context.privateFileSends.last)
|
||||
ttlCoordinator.handleTransferEvent(.completed(
|
||||
id: ttlInitial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
clock.advance(by: 10)
|
||||
ttlCoordinator._test_expireReconnectRetries()
|
||||
|
||||
#expect(ttlCoordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.deliveryStatusUpdates.contains {
|
||||
$0.status == .failed(
|
||||
reason: String(
|
||||
localized:
|
||||
"content.delivery.reason.private_media_delivery_unconfirmed",
|
||||
defaultValue: "Delivery could not be confirmed"
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func retentionAndPerReconnectWorkAreBounded() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let limits = PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 2,
|
||||
maxRetainedBytes: 10,
|
||||
maxRetriesPerMessage: 2,
|
||||
retentionSeconds: 120,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
let content = Data("voice".utf8)
|
||||
return BitchatFilePacket(
|
||||
fileName: url.lastPathComponent,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let fileNames = [
|
||||
"voice_6666777788889999.m4a",
|
||||
"voice_777788889999aaaa.m4a",
|
||||
"voice_88889999aaaabbbb.m4a"
|
||||
]
|
||||
var roots: [URL] = []
|
||||
defer {
|
||||
for root in roots {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
}
|
||||
|
||||
for fileName in fileNames {
|
||||
let url = try makeCoordinatorVoiceURL(
|
||||
fileName: fileName,
|
||||
bytes: Data("voice".utf8)
|
||||
)
|
||||
roots.append(url.deletingLastPathComponent())
|
||||
// The production preparer preserves this stable filename.
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
}
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 3 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 2)
|
||||
#expect(coordinator.retainedReconnectRetryBytes <= 10)
|
||||
|
||||
for send in context.privateFileSends {
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: send.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
}
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_9999aaaabbbbcccc.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
context.privateChats[peerID]?.first?.id
|
||||
)
|
||||
let transferID = try #require(
|
||||
context.privateFileSends.first?.transferId
|
||||
)
|
||||
|
||||
coordinator.cancelMediaSend(messageID: messageID)
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: transferID,
|
||||
sentFragments: 0,
|
||||
totalFragments: 1
|
||||
))
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||
#expect(context.cancelledTransfers == [transferID])
|
||||
#expect(context.removedMessages.map(\.messageID) == [
|
||||
messageID
|
||||
])
|
||||
#expect(!context.deliveryStatusUpdates.contains {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCoordinatorVoiceURL(
|
||||
fileName: String,
|
||||
bytes: Data = Data("voice".utf8)
|
||||
) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"media-retry-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let url = directory.appendingPathComponent(fileName)
|
||||
try bytes.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
private func makeCoordinatorTestImageURL() throws -> URL {
|
||||
|
||||
Reference in New Issue
Block a user