feat(ios): native receiver app (Swift, min iOS 17)

A native iOS receiver so an iPhone can act as the second receiver, speaking the existing signaling + RTP protocol (no C++ changes) and mirroring the Android receiver (Phase 8) source-to-source.

- RTP core (header/packet, jitter buffer, H.264 depacketizer) ported from the Android receiver
- BSD-socket signaling server (dual-stack, most-recent-peer, never-throwing sends) + NSBonjourServices
- VideoToolbox H.264 decode (in-band SPS/PPS, real-time, rebuilds on size change) -> AVSampleBufferDisplayLayer
- PLI keyframe recovery (500 ms) + pendingOffer for late surface attach
- XcodeGen project + bootstrap.sh; XCTest port of the Android suite + new coverage
- .gitignore for generated artifacts; CHANGELOG; PHASES + MEMORY updated

Status: authored; on-device validation pending a Mac + Xcode 26 + iPhone 16.
This commit is contained in:
2026-09-10 21:31:53 +02:00
parent 4f93c7cd20
commit c43dd3ca4a
31 changed files with 2282 additions and 1 deletions
@@ -0,0 +1,27 @@
import XCTest
@testable import Receiver
final class AvccConverterTests: XCTestCase {
func testNalUnits() {
let annexB: [UInt8] = [0, 0, 1, 0x41, 0x11, 0, 0, 1, 0x67, 0xAA]
XCTAssertEqual(AvccConverter.nalUnits(annexB), [[0x41, 0x11], [0x67, 0xAA]])
}
func testToAvcc() {
let annexB: [UInt8] = [0, 0, 1, 0x41, 0x11, 0, 0, 1, 0x67, 0xAA]
let avcc = AvccConverter.toAvcc(annexB)
XCTAssertEqual(avcc, [0, 0, 0, 2, 0x41, 0x11, 0, 0, 0, 2, 0x67, 0xAA])
}
func testFromAvccRoundtrip() {
let units: [[UInt8]] = [[0x41, 0x11], [0x67, 0xAA, 0xBB]]
let annexB = [0, 0, 1] + units[0] + [0, 0, 1] + units[1]
let avcc = AvccConverter.toAvcc(annexB)
XCTAssertEqual(AvccConverter.fromAvcc(avcc ?? []), units)
}
func testEmptyReturnsNil() {
XCTAssertNil(AvccConverter.toAvcc([UInt8]()))
XCTAssertNil(AvccConverter.toAvcc([0, 0, 1]))
}
}
@@ -0,0 +1,103 @@
import XCTest
@testable import Receiver
final class H264DepacketizerTests: XCTestCase {
private let start: [UInt8] = [0x00, 0x00, 0x01]
private func packet(seq: Int, ts: Int, payload: [UInt8], marker: Bool = false) -> RtpPacket {
RtpPacket(header: RtpHeader(sequenceNumber: seq, timestamp: ts, marker: marker), payload: payload)
}
func testSingleNalTwoPackets() {
let d = H264Depacketizer()
// SPS (type 7) then a slice (type 5), closed by the marker.
let first = d.depacketize(packet(seq: 1, ts: 100, payload: [0x67, 0xAA, 0xBB]))
XCTAssertNil(first.accessUnit)
let second = d.depacketize(packet(seq: 2, ts: 100, payload: [0x41, 0x01, 0x02], marker: true))
XCTAssertEqual(second.accessUnit, start + [0x67, 0xAA, 0xBB] + start + [0x41, 0x01, 0x02])
XCTAssertTrue(second.isKeyFrame)
XCTAssertFalse(second.frameDropped)
}
func testFuAReassembly() {
let d = H264Depacketizer()
// NAL: header 0x41 (type 1, NRI 2) + payload 0x11 0x22 0x33 0x44.
// FU indicator = (0x41 & 0xE0) | 28 = 0x5C.
let indicator: UInt8 = 0x5C
_ = d.depacketize(packet(seq: 1, ts: 100, payload: [indicator, 0x81, 0x11]))
_ = d.depacketize(packet(seq: 2, ts: 100, payload: [indicator, 0x01, 0x22]))
let done = d.depacketize(packet(seq: 3, ts: 100, payload: [indicator, 0x41, 0x33, 0x44], marker: true))
XCTAssertEqual(done.accessUnit, start + [0x41, 0x11, 0x22, 0x33, 0x44])
XCTAssertFalse(done.frameDropped)
}
func testDropsGappedFrames() {
let d = H264Depacketizer()
_ = d.depacketize(packet(seq: 1, ts: 100, payload: [0x41, 0x01]))
// seq 2 is missing; the frame must be reported dropped, not delivered.
let tail = d.depacketize(packet(seq: 3, ts: 100, payload: [0x41, 0x02], marker: true))
XCTAssertNil(tail.accessUnit)
XCTAssertTrue(tail.frameDropped)
}
func testSeparateFramesByMarker() {
let d = H264Depacketizer()
let au1 = d.depacketize(packet(seq: 1, ts: 100, payload: [0x41, 0xAA], marker: true))
XCTAssertEqual(au1.accessUnit, start + [0x41, 0xAA])
let au2 = d.depacketize(packet(seq: 2, ts: 200, payload: [0x41, 0xBB], marker: true))
XCTAssertEqual(au2.accessUnit, start + [0x41, 0xBB])
}
func testDropsFuWithoutStart() {
let d = H264Depacketizer()
// Continuation (no S bit) without any start packet.
let result = d.depacketize(packet(seq: 1, ts: 100, payload: [0x7C, 0x41, 0x11], marker: true))
XCTAssertNil(result.accessUnit)
XCTAssertTrue(result.frameDropped)
}
func testDropsStillFragmentedAtMarker() {
let d = H264Depacketizer()
_ = d.depacketize(packet(seq: 1, ts: 100, payload: [0x7C, 0x81, 0x11]))
// Marker arrives while the FU-A NAL is still open.
let result = d.depacketize(packet(seq: 2, ts: 100, payload: [0x41, 0x01], marker: true))
XCTAssertNil(result.accessUnit)
XCTAssertTrue(result.frameDropped)
}
func testDropsUnsupportedPacketization() {
let d = H264Depacketizer()
let stapA: UInt8 = 24 // STAP-A
let result = d.depacketize(packet(seq: 1, ts: 100, payload: [stapA, 0x00, 0x05, 0x41, 0x01], marker: true))
XCTAssertNil(result.accessUnit)
XCTAssertTrue(result.frameDropped)
}
func testDropsTimestampChangeWithoutMarker() {
let d = H264Depacketizer()
_ = d.depacketize(packet(seq: 1, ts: 100, payload: [0x41, 0x01]))
// The stale frame is reported dropped, but this packet starts (and
// closes) the next frame matching the C++ depacketizer.
let result = d.depacketize(packet(seq: 2, ts: 200, payload: [0x41, 0x02], marker: true))
XCTAssertEqual(result.accessUnit, start + [0x41, 0x02])
XCTAssertTrue(result.frameDropped)
}
func testKeyframeDetectionRequiresParameterSets() {
let d = H264Depacketizer()
let plain = d.depacketize(packet(seq: 1, ts: 100, payload: [0x41, 0x01], marker: true))
XCTAssertFalse(plain.isKeyFrame)
let d2 = H264Depacketizer()
let withSps = d2.depacketize(packet(seq: 1, ts: 100, payload: [0x67, 0xAA, 0x88, 0x68, 0xBB, 0x41, 0x01], marker: true))
XCTAssertTrue(withSps.isKeyFrame)
}
func testDropsShortFuPackets() {
let d = H264Depacketizer()
// FU-A packet without its FU header byte.
let result = d.depacketize(packet(seq: 1, ts: 100, payload: [0x7C], marker: true))
XCTAssertNil(result.accessUnit)
XCTAssertTrue(result.frameDropped)
}
}
+57
View File
@@ -0,0 +1,57 @@
import XCTest
@testable import Receiver
final class JitterBufferTests: XCTestCase {
private func packet(seq: Int, ts: Int = 100) -> RtpPacket {
RtpPacket(header: RtpHeader(sequenceNumber: seq, timestamp: ts), payload: [UInt8(seq)])
}
func testInOrderReleasesImmediately() {
let jitter = JitterBuffer()
XCTAssertEqual(jitter.push(packet(seq: 1)).map { $0.header.sequenceNumber }, [1])
XCTAssertEqual(jitter.push(packet(seq: 2)).map { $0.header.sequenceNumber }, [2])
XCTAssertEqual(jitter.push(packet(seq: 3)).map { $0.header.sequenceNumber }, [3])
}
func testReordersOutOfOrderPackets() {
let jitter = JitterBuffer()
XCTAssertEqual(jitter.push(packet(seq: 1)).map { $0.header.sequenceNumber }, [1])
XCTAssertTrue(jitter.push(packet(seq: 3)).isEmpty)
XCTAssertEqual(jitter.push(packet(seq: 2)).map { $0.header.sequenceNumber }, [2, 3])
}
func testOverflowReleasesInOrderAndAdvances() {
let jitter = JitterBuffer(maxDepth: 4)
XCTAssertEqual(jitter.push(packet(seq: 1)).map { $0.header.sequenceNumber }, [1])
// seq 2 is lost; 3..6 stay buffered (within the depth bound).
for seq in 3...6 {
XCTAssertTrue(jitter.push(packet(seq: seq)).isEmpty)
}
// seq 7 overflows the buffer: 3..7 flush in order.
XCTAssertEqual(jitter.push(packet(seq: 7)).map { $0.header.sequenceNumber }, [3, 4, 5, 6, 7])
// Delivery continues in order afterwards.
XCTAssertEqual(jitter.push(packet(seq: 8)).map { $0.header.sequenceNumber }, [8])
XCTAssertEqual(jitter.push(packet(seq: 9)).map { $0.header.sequenceNumber }, [9])
}
func testDiscardsStragglers() {
let jitter = JitterBuffer(maxDepth: 4)
_ = jitter.push(packet(seq: 1))
for seq in 3...8 {
_ = jitter.push(packet(seq: seq))
}
XCTAssertTrue(jitter.push(packet(seq: 9)).isNotEmpty)
// seq 4 is now far behind the expected sequence: discarded, not delivered.
XCTAssertTrue(jitter.push(packet(seq: 4)).isEmpty)
// In-order delivery continues from 10.
XCTAssertEqual(jitter.push(packet(seq: 10)).map { $0.header.sequenceNumber }, [10])
}
func testClearResetsState() {
let jitter = JitterBuffer()
_ = jitter.push(packet(seq: 5))
jitter.clear()
// A completely different sequence now starts fresh.
XCTAssertEqual(jitter.push(packet(seq: 100)).map { $0.header.sequenceNumber }, [100])
}
}
@@ -0,0 +1,44 @@
import XCTest
@testable import Receiver
final class LineAssemblerTests: XCTestCase {
private func bytes(_ s: String) -> [UInt8] { Array(s.utf8) }
func testSingleLine() {
let a = LineAssembler()
XCTAssertEqual(a.feed(bytes("hello")), [])
XCTAssertEqual(a.feed(bytes("\n")), ["hello"])
}
func testMultipleLinesInOneChunk() {
let a = LineAssembler()
XCTAssertEqual(a.feed(bytes("one\ntwo\nthree\n")), ["one", "two", "three"])
}
func testDropsCR() {
let a = LineAssembler()
XCTAssertEqual(a.feed(bytes("line\r\n")), ["line"])
}
func testIgnoresEmptyLine() {
let a = LineAssembler()
XCTAssertEqual(a.feed(bytes("\n")), [])
}
func testSplitAcrossChunks() {
let a = LineAssembler()
let payload = "{\"session_id\":\"x\"}"
var lines: [String] = []
lines += a.feed(Array(payload.prefix(5)))
lines += a.feed(Array(payload.suffix(from: 5)))
lines += a.feed(["\n".utf8.first!])
XCTAssertEqual(lines, [payload])
}
func testDropsOversizedLine() {
let a = LineAssembler()
let big = String(repeating: "a", count: SignalingMessage.maxMessageBytes + 1)
_ = a.feed(Array(big.utf8))
XCTAssertEqual(a.feed(bytes("\n")), [])
}
}
+24
View File
@@ -0,0 +1,24 @@
import XCTest
@testable import Receiver
final class NalExtractorTests: XCTestCase {
func testExtractsSpsAndPps() {
// Annex-B: SPS (type 7), PPS (type 8), slice (type 5).
let annexB: [UInt8] = [0, 0, 1, 0x67, 0xAA, 0, 0, 1, 0x68, 0xBB, 0, 0, 1, 0x41, 0x01]
let sets = NalExtractor.parameterSets(annexB)
XCTAssertEqual(sets?.sps, [0x67, 0xAA])
XCTAssertEqual(sets?.pps, [0x68, 0xBB])
}
func testReturnsNilWithoutBoth() {
let annexB: [UInt8] = [0, 0, 1, 0x67, 0xAA, 0, 0, 1, 0x41, 0x01] // SPS but no PPS
XCTAssertNil(NalExtractor.parameterSets(annexB))
}
func testPicksFirstOfEach() {
let annexB: [UInt8] = [0, 0, 1, 0x67, 0x11, 0, 0, 1, 0x68, 0x22, 0, 0, 1, 0x67, 0x33, 0, 0, 1, 0x68, 0x44]
let sets = NalExtractor.parameterSets(annexB)
XCTAssertEqual(sets?.sps, [0x67, 0x11])
XCTAssertEqual(sets?.pps, [0x68, 0x22])
}
}
+87
View File
@@ -0,0 +1,87 @@
import XCTest
@testable import Receiver
final class RtpHeaderTests: XCTestCase {
func testRoundtrip() {
let header = RtpHeader(version: 2, padding: false, extensionHeader: false, csrcCount: 0,
marker: true, payloadType: 96, sequenceNumber: 0xABCD,
timestamp: 0xDEADBEEF, ssrc: 0x12345678)
let wire = header.serialize()
XCTAssertEqual(wire.count, 12)
XCTAssertEqual(RtpHeader.parse(wire), header)
}
func testRejectsBadVersion() {
var wire = RtpHeader().serialize()
wire[0] = (wire[0] & 0x3F) | (1 << 6)
XCTAssertNil(RtpHeader.parse(wire))
}
func testRejectsShortInput() {
let header = RtpHeader()
XCTAssertNil(RtpHeader.parse(Array(header.serialize().prefix(11))))
XCTAssertNil(RtpHeader.parse([]))
}
func testPreservesFlags() {
let header = RtpHeader(padding: true, csrcCount: 2, marker: true, payloadType: 63)
let parsed = RtpHeader.parse(header.serialize())
XCTAssertEqual(parsed?.padding, true)
XCTAssertEqual(parsed?.csrcCount, 2)
XCTAssertEqual(parsed?.marker, true)
XCTAssertEqual(parsed?.payloadType, 63)
}
}
final class RtpPacketTests: XCTestCase {
private func header(seq: Int, marker: Bool = false) -> RtpHeader {
RtpHeader(sequenceNumber: seq, payloadType: 96, marker: marker)
}
func testRoundtripWithPayload() {
let packet = RtpPacket(header: header(seq: 7), payload: [0x11, 0x22, 0x33])
let wire = packet.header.serialize() + packet.payload
let parsed = RtpPacket.parse(wire)
XCTAssertEqual(parsed?.header, header(seq: 7))
XCTAssertEqual(parsed?.payload, [0x11, 0x22, 0x33])
}
func testSkipsCsrcList() {
let header = RtpHeader(csrcCount: 1, sequenceNumber: 3)
let wire = header.serialize() + [0x0A, 0x00, 0x00, 0x01] + [0x99]
let parsed = RtpPacket.parse(wire)
XCTAssertEqual(parsed?.header.csrcCount, 1)
XCTAssertEqual(parsed?.payload, [0x99])
}
func testSkipsExtensionHeader() {
let header = RtpHeader(extensionHeader: true, sequenceNumber: 4)
// profile=0x0001, length=1 word, one word of data.
let wire = header.serialize() + [0x00, 0x01, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF] + [0x77]
let parsed = RtpPacket.parse(wire)
XCTAssertEqual(parsed?.payload, [0x77])
}
func testStripsPadding() {
let header = RtpHeader(padding: true, sequenceNumber: 5)
// Payload byte, one padding zero, size byte (2 = padding incl. itself).
let wire = header.serialize() + [0x55, 0x00, 0x02]
let parsed = RtpPacket.parse(wire)
XCTAssertEqual(parsed?.payload, [0x55])
}
func testRejectsTruncatedCsrcAndExtension() {
let csrc = RtpHeader(csrcCount: 1).serialize()
XCTAssertNil(RtpPacket.parse(csrc)) // 12 bytes, needs 16
let ext = RtpHeader(extensionHeader: true).serialize() + [0x00, 0x01]
XCTAssertNil(RtpPacket.parse(ext)) // extension length field cut off
}
func testRejectsBadPadding() {
let zeroPad = RtpHeader(padding: true).serialize() + [0x00]
XCTAssertNil(RtpPacket.parse(zeroPad))
let oversized = RtpHeader(padding: true).serialize() + [0x00, 0x00, 0x05]
XCTAssertNil(RtpPacket.parse(oversized))
XCTAssertNil(RtpPacket.parse([UInt8](repeating: 0, count: 11)))
}
}
@@ -0,0 +1,54 @@
import XCTest
@testable import Receiver
final class SignalingMessageTests: XCTestCase {
func testOfferRoundtrip() {
let offer = SignalingMessage.offer(sessionId: "s-1", codec: "h264", width: 0, height: 0,
frameRateNum: 30, frameRateDen: 1, rtpAddress: "10.0.0.5", rtpPort: 1234)
let line = SignalingMessage.serialize(offer).trimmingCharacters(in: .newlines)
let parsed = SignalingMessage.parse(line)
guard case let .offer(sid, codec, w, h, num, den, addr, port) = parsed else { return XCTFail() }
XCTAssertEqual(sid, "s-1")
XCTAssertEqual(codec, "h264")
XCTAssertEqual(w, 0)
XCTAssertEqual(h, 0)
XCTAssertEqual(num, 30)
XCTAssertEqual(den, 1)
XCTAssertEqual(addr, "10.0.0.5")
XCTAssertEqual(port, 1234)
}
func testAnswerRoundtrip() {
let answer = SignalingMessage.answer(sessionId: "s-2", rtpAddress: "", rtpPort: 5004,
displayWidth: 1179, displayHeight: 2556)
let line = SignalingMessage.serialize(answer).trimmingCharacters(in: .newlines)
let parsed = SignalingMessage.parse(line)
guard case let .answer(sid, addr, port, dw, dh) = parsed else { return XCTFail() }
XCTAssertEqual(sid, "s-2")
XCTAssertEqual(addr, "")
XCTAssertEqual(port, 5004)
XCTAssertEqual(dw, 1179)
XCTAssertEqual(dh, 2556)
}
func testPliRoundtrip() {
let pli = SignalingMessage.pli(sessionId: "s-3")
let line = SignalingMessage.serialize(pli).trimmingCharacters(in: .newlines)
let parsed = SignalingMessage.parse(line)
guard case let .pli(sid) = parsed else { return XCTFail() }
XCTAssertEqual(sid, "s-3")
}
func testRejectsUnknownType() {
XCTAssertNil(SignalingMessage.parse("{\"type\":\"bogus\"}"))
}
func testRejectsInvalidJson() {
XCTAssertNil(SignalingMessage.parse("not json"))
}
func testRejectsOversizedLine() {
let big = String(repeating: "a", count: SignalingMessage.maxMessageBytes + 1)
XCTAssertNil(SignalingMessage.parse(big))
}
}