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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")), [])
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user