| 1 | import CoreVideo |
| 2 | import LiveKitWebRTC |
| 3 | import XCTest |
| 4 | |
| 5 | @testable import CallsBroadcastKit |
| 6 | @testable import CallsKit |
| 7 | |
| 8 | /// The iPhone's screen travels from the broadcast extension to the app |
| 9 | /// over a Unix socket, and nothing about that needs an iPhone: the same |
| 10 | /// sender and inbox run here, over a real socket, with real pixel |
| 11 | /// buffers. What cannot run here is ReplayKit itself, the App Group |
| 12 | /// container and the extension's memory ceiling — those need a device. |
| 13 | final class BroadcastTests: XCTestCase { |
| 14 | private var path = "" |
| 15 | |
| 16 | override func setUp() { |
| 17 | // Short, because a socket path has to fit in 104 bytes. |
| 18 | path = "/tmp/calls-\(UUID().uuidString.prefix(8)).sock" |
| 19 | } |
| 20 | override func tearDown() { unlink(path) } |
| 21 | |
| 22 | func testTheHeaderRoundTripsAndRefusesWhatItCannotUse() { |
| 23 | let header = BroadcastFrameHeader( |
| 24 | width: 640, height: 360, orientation: 8, timestampNs: 123_456_789, videoRange: true) |
| 25 | var bytes = [UInt8](repeating: 0, count: BroadcastFrameHeader.size) |
| 26 | bytes.withUnsafeMutableBytes { header.write(to: $0.baseAddress!) } |
| 27 | XCTAssertEqual(bytes.withUnsafeBytes { BroadcastFrameHeader.read($0.baseAddress!) }, header) |
| 28 | // Odd sizes, absurd sizes, a length that disagrees with them, and a |
| 29 | // stranger's magic are all refused: the length is what gets allocated. |
| 30 | for (offset, value) in [(8, UInt32(641)), (12, UInt32(100_000)), (24, UInt32(12)), (0, UInt32(7))] { |
| 31 | var bad = bytes |
| 32 | bad.withUnsafeMutableBytes { $0.storeBytes(of: value.littleEndian, toByteOffset: offset, as: UInt32.self) } |
| 33 | XCTAssertNil(bad.withUnsafeBytes { BroadcastFrameHeader.read($0.baseAddress!) }, "offset \(offset)") |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func testAFrameCrossesTheSocketSmallerAndIntact() throws { |
| 38 | let inbox = BroadcastInbox(path: path) |
| 39 | let connected = expectation(description: "connected") |
| 40 | let arrived = expectation(description: "frame") |
| 41 | var received: (CVPixelBuffer, UInt16)? |
| 42 | inbox.onConnection = { connection in |
| 43 | connection.onFrame = { pixels, orientation in |
| 44 | guard received == nil else { return } |
| 45 | received = (pixels, orientation) |
| 46 | arrived.fulfill() |
| 47 | } |
| 48 | connection.resume() |
| 49 | connected.fulfill() |
| 50 | } |
| 51 | try inbox.start() |
| 52 | defer { inbox.stop() } |
| 53 | |
| 54 | let sender = BroadcastSender(path: path, longest: 640, framesPerSecond: 100) |
| 55 | XCTAssertTrue(sender.connect(timeout: 2)) |
| 56 | defer { sender.close() } |
| 57 | wait(for: [connected], timeout: 2) |
| 58 | // A 1280x720 frame, luma 100 and chroma (60, 200) throughout, as |
| 59 | // ReplayKit's full-range bi-planar buffers are laid out. |
| 60 | sender.send(try nv12(width: 1280, height: 720), orientation: 6, timestampNs: 1) |
| 61 | wait(for: [arrived], timeout: 2) |
| 62 | |
| 63 | let (pixels, orientation) = try XCTUnwrap(received) |
| 64 | XCTAssertEqual(orientation, 6) |
| 65 | XCTAssertEqual(CVPixelBufferGetWidth(pixels), 640) |
| 66 | XCTAssertEqual(CVPixelBufferGetHeight(pixels), 360) |
| 67 | XCTAssertEqual( |
| 68 | CVPixelBufferGetPixelFormatType(pixels), kCVPixelFormatType_420YpCbCr8BiPlanarFullRange) |
| 69 | CVPixelBufferLockBaseAddress(pixels, .readOnly) |
| 70 | defer { CVPixelBufferUnlockBaseAddress(pixels, .readOnly) } |
| 71 | let luma = CVPixelBufferGetBaseAddressOfPlane(pixels, 0)!.assumingMemoryBound(to: UInt8.self) |
| 72 | let chroma = CVPixelBufferGetBaseAddressOfPlane(pixels, 1)!.assumingMemoryBound(to: UInt8.self) |
| 73 | let lumaStride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 0) |
| 74 | let chromaStride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 1) |
| 75 | XCTAssertEqual(luma[0], 100) |
| 76 | XCTAssertEqual(luma[359 * lumaStride + 639], 100) |
| 77 | XCTAssertEqual(chroma[0], 60) |
| 78 | XCTAssertEqual(chroma[1], 200) |
| 79 | XCTAssertEqual(chroma[179 * chromaStride + 638], 60) |
| 80 | XCTAssertEqual(chroma[179 * chromaStride + 639], 200) |
| 81 | } |
| 82 | |
| 83 | func testTheExtensionGoingAwayEndsTheConnection() throws { |
| 84 | let inbox = BroadcastInbox(path: path) |
| 85 | let closed = expectation(description: "closed") |
| 86 | inbox.onConnection = { connection in |
| 87 | connection.onClose = { closed.fulfill() } |
| 88 | connection.resume() |
| 89 | } |
| 90 | try inbox.start() |
| 91 | defer { inbox.stop() } |
| 92 | let sender = BroadcastSender(path: path) |
| 93 | XCTAssertTrue(sender.connect(timeout: 2)) |
| 94 | sender.close() |
| 95 | wait(for: [closed], timeout: 2) |
| 96 | } |
| 97 | |
| 98 | func testStoppingInTheAppReachesTheExtension() throws { |
| 99 | // The app closing its end is the whole of "stop sharing": the |
| 100 | // extension learns it from its next write, and ends the broadcast. |
| 101 | let inbox = BroadcastInbox(path: path) |
| 102 | var held: BroadcastConnection? |
| 103 | let connected = expectation(description: "connected") |
| 104 | inbox.onConnection = { connection in |
| 105 | held = connection |
| 106 | connection.resume() |
| 107 | connected.fulfill() |
| 108 | } |
| 109 | try inbox.start() |
| 110 | defer { inbox.stop() } |
| 111 | let sender = BroadcastSender(path: path, longest: 64, framesPerSecond: 1000) |
| 112 | let gone = expectation(description: "extension told") |
| 113 | sender.onClosed = { gone.fulfill() } |
| 114 | XCTAssertTrue(sender.connect(timeout: 2)) |
| 115 | wait(for: [connected], timeout: 2) |
| 116 | held?.close() |
| 117 | let frame = try nv12(width: 64, height: 64) |
| 118 | let until = Date().addingTimeInterval(2) |
| 119 | while Date() < until { |
| 120 | sender.send(frame, orientation: 1, timestampNs: 0) |
| 121 | if XCTWaiter.wait(for: [gone], timeout: 0.05) == .completed { return } |
| 122 | } |
| 123 | XCTFail("the extension never learned the app had stopped") |
| 124 | } |
| 125 | |
| 126 | func testOnlyOneBroadcastAtATime() throws { |
| 127 | let inbox = BroadcastInbox(path: path) |
| 128 | var connections = 0 |
| 129 | let first = expectation(description: "first") |
| 130 | inbox.onConnection = { connection in |
| 131 | connections += 1 |
| 132 | connection.resume() |
| 133 | first.fulfill() |
| 134 | } |
| 135 | try inbox.start() |
| 136 | defer { inbox.stop() } |
| 137 | let one = BroadcastSender(path: path) |
| 138 | XCTAssertTrue(one.connect(timeout: 2)) |
| 139 | defer { one.close() } |
| 140 | wait(for: [first], timeout: 2) |
| 141 | let two = BroadcastSender(path: path) |
| 142 | let refused = expectation(description: "second refused") |
| 143 | two.onClosed = { refused.fulfill() } |
| 144 | XCTAssertTrue(two.connect(timeout: 2)) |
| 145 | defer { two.close() } |
| 146 | let frame = try nv12(width: 64, height: 64) |
| 147 | let until = Date().addingTimeInterval(2) |
| 148 | while Date() < until { |
| 149 | two.send(frame, orientation: 1, timestampNs: 0) |
| 150 | if XCTWaiter.wait(for: [refused], timeout: 0.05) == .completed { break } |
| 151 | } |
| 152 | XCTAssertEqual(connections, 1) |
| 153 | } |
| 154 | |
| 155 | func testReplayKitOrientationBecomesAnUprightRotation() { |
| 156 | XCTAssertEqual(broadcastRotation(1), ._0) |
| 157 | XCTAssertEqual(broadcastRotation(3), ._180) |
| 158 | XCTAssertEqual(broadcastRotation(8), ._90) |
| 159 | XCTAssertEqual(broadcastRotation(6), ._270) |
| 160 | XCTAssertEqual(broadcastRotation(0), ._0) |
| 161 | } |
| 162 | |
| 163 | private func nv12(width: Int, height: Int) throws -> CVPixelBuffer { |
| 164 | var made: CVPixelBuffer? |
| 165 | CVPixelBufferCreate( |
| 166 | nil, width, height, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, |
| 167 | [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &made) |
| 168 | let pixels = try XCTUnwrap(made) |
| 169 | CVPixelBufferLockBaseAddress(pixels, []) |
| 170 | defer { CVPixelBufferUnlockBaseAddress(pixels, []) } |
| 171 | let luma = CVPixelBufferGetBaseAddressOfPlane(pixels, 0)! |
| 172 | memset(luma, 100, CVPixelBufferGetBytesPerRowOfPlane(pixels, 0) * height) |
| 173 | let chroma = CVPixelBufferGetBaseAddressOfPlane(pixels, 1)!.assumingMemoryBound(to: UInt8.self) |
| 174 | let stride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 1) |
| 175 | for row in 0..<(height / 2) { |
| 176 | for pair in 0..<(width / 2) { |
| 177 | chroma[row * stride + pair * 2] = 60 |
| 178 | chroma[row * stride + pair * 2 + 1] = 200 |
| 179 | } |
| 180 | } |
| 181 | return pixels |
| 182 | } |
| 183 | } |
| 184 | |