import CoreVideo import LiveKitWebRTC import XCTest @testable import CallsBroadcastKit @testable import CallsKit /// The iPhone's screen travels from the broadcast extension to the app /// over a Unix socket, and nothing about that needs an iPhone: the same /// sender and inbox run here, over a real socket, with real pixel /// buffers. What cannot run here is ReplayKit itself, the App Group /// container and the extension's memory ceiling — those need a device. final class BroadcastTests: XCTestCase { private var path = "" override func setUp() { // Short, because a socket path has to fit in 104 bytes. path = "/tmp/calls-\(UUID().uuidString.prefix(8)).sock" } override func tearDown() { unlink(path) } func testTheHeaderRoundTripsAndRefusesWhatItCannotUse() { let header = BroadcastFrameHeader( width: 640, height: 360, orientation: 8, timestampNs: 123_456_789, videoRange: true) var bytes = [UInt8](repeating: 0, count: BroadcastFrameHeader.size) bytes.withUnsafeMutableBytes { header.write(to: $0.baseAddress!) } XCTAssertEqual(bytes.withUnsafeBytes { BroadcastFrameHeader.read($0.baseAddress!) }, header) // Odd sizes, absurd sizes, a length that disagrees with them, and a // stranger's magic are all refused: the length is what gets allocated. for (offset, value) in [(8, UInt32(641)), (12, UInt32(100_000)), (24, UInt32(12)), (0, UInt32(7))] { var bad = bytes bad.withUnsafeMutableBytes { $0.storeBytes(of: value.littleEndian, toByteOffset: offset, as: UInt32.self) } XCTAssertNil(bad.withUnsafeBytes { BroadcastFrameHeader.read($0.baseAddress!) }, "offset \(offset)") } } func testAFrameCrossesTheSocketSmallerAndIntact() throws { let inbox = BroadcastInbox(path: path) let connected = expectation(description: "connected") let arrived = expectation(description: "frame") var received: (CVPixelBuffer, UInt16)? inbox.onConnection = { connection in connection.onFrame = { pixels, orientation in guard received == nil else { return } received = (pixels, orientation) arrived.fulfill() } connection.resume() connected.fulfill() } try inbox.start() defer { inbox.stop() } let sender = BroadcastSender(path: path, longest: 640, framesPerSecond: 100) XCTAssertTrue(sender.connect(timeout: 2)) defer { sender.close() } wait(for: [connected], timeout: 2) // A 1280x720 frame, luma 100 and chroma (60, 200) throughout, as // ReplayKit's full-range bi-planar buffers are laid out. sender.send(try nv12(width: 1280, height: 720), orientation: 6, timestampNs: 1) wait(for: [arrived], timeout: 2) let (pixels, orientation) = try XCTUnwrap(received) XCTAssertEqual(orientation, 6) XCTAssertEqual(CVPixelBufferGetWidth(pixels), 640) XCTAssertEqual(CVPixelBufferGetHeight(pixels), 360) XCTAssertEqual( CVPixelBufferGetPixelFormatType(pixels), kCVPixelFormatType_420YpCbCr8BiPlanarFullRange) CVPixelBufferLockBaseAddress(pixels, .readOnly) defer { CVPixelBufferUnlockBaseAddress(pixels, .readOnly) } let luma = CVPixelBufferGetBaseAddressOfPlane(pixels, 0)!.assumingMemoryBound(to: UInt8.self) let chroma = CVPixelBufferGetBaseAddressOfPlane(pixels, 1)!.assumingMemoryBound(to: UInt8.self) let lumaStride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 0) let chromaStride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 1) XCTAssertEqual(luma[0], 100) XCTAssertEqual(luma[359 * lumaStride + 639], 100) XCTAssertEqual(chroma[0], 60) XCTAssertEqual(chroma[1], 200) XCTAssertEqual(chroma[179 * chromaStride + 638], 60) XCTAssertEqual(chroma[179 * chromaStride + 639], 200) } func testTheExtensionGoingAwayEndsTheConnection() throws { let inbox = BroadcastInbox(path: path) let closed = expectation(description: "closed") inbox.onConnection = { connection in connection.onClose = { closed.fulfill() } connection.resume() } try inbox.start() defer { inbox.stop() } let sender = BroadcastSender(path: path) XCTAssertTrue(sender.connect(timeout: 2)) sender.close() wait(for: [closed], timeout: 2) } func testStoppingInTheAppReachesTheExtension() throws { // The app closing its end is the whole of "stop sharing": the // extension learns it from its next write, and ends the broadcast. let inbox = BroadcastInbox(path: path) var held: BroadcastConnection? let connected = expectation(description: "connected") inbox.onConnection = { connection in held = connection connection.resume() connected.fulfill() } try inbox.start() defer { inbox.stop() } let sender = BroadcastSender(path: path, longest: 64, framesPerSecond: 1000) let gone = expectation(description: "extension told") sender.onClosed = { gone.fulfill() } XCTAssertTrue(sender.connect(timeout: 2)) wait(for: [connected], timeout: 2) held?.close() let frame = try nv12(width: 64, height: 64) let until = Date().addingTimeInterval(2) while Date() < until { sender.send(frame, orientation: 1, timestampNs: 0) if XCTWaiter.wait(for: [gone], timeout: 0.05) == .completed { return } } XCTFail("the extension never learned the app had stopped") } func testOnlyOneBroadcastAtATime() throws { let inbox = BroadcastInbox(path: path) var connections = 0 let first = expectation(description: "first") inbox.onConnection = { connection in connections += 1 connection.resume() first.fulfill() } try inbox.start() defer { inbox.stop() } let one = BroadcastSender(path: path) XCTAssertTrue(one.connect(timeout: 2)) defer { one.close() } wait(for: [first], timeout: 2) let two = BroadcastSender(path: path) let refused = expectation(description: "second refused") two.onClosed = { refused.fulfill() } XCTAssertTrue(two.connect(timeout: 2)) defer { two.close() } let frame = try nv12(width: 64, height: 64) let until = Date().addingTimeInterval(2) while Date() < until { two.send(frame, orientation: 1, timestampNs: 0) if XCTWaiter.wait(for: [refused], timeout: 0.05) == .completed { break } } XCTAssertEqual(connections, 1) } func testReplayKitOrientationBecomesAnUprightRotation() { XCTAssertEqual(broadcastRotation(1), ._0) XCTAssertEqual(broadcastRotation(3), ._180) XCTAssertEqual(broadcastRotation(8), ._90) XCTAssertEqual(broadcastRotation(6), ._270) XCTAssertEqual(broadcastRotation(0), ._0) } private func nv12(width: Int, height: Int) throws -> CVPixelBuffer { var made: CVPixelBuffer? CVPixelBufferCreate( nil, width, height, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &made) let pixels = try XCTUnwrap(made) CVPixelBufferLockBaseAddress(pixels, []) defer { CVPixelBufferUnlockBaseAddress(pixels, []) } let luma = CVPixelBufferGetBaseAddressOfPlane(pixels, 0)! memset(luma, 100, CVPixelBufferGetBytesPerRowOfPlane(pixels, 0) * height) let chroma = CVPixelBufferGetBaseAddressOfPlane(pixels, 1)!.assumingMemoryBound(to: UInt8.self) let stride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 1) for row in 0..<(height / 2) { for pair in 0..<(width / 2) { chroma[row * stride + pair * 2] = 60 chroma[row * stride + pair * 2 + 1] = 200 } } return pixels } }