feat(android): native receiver app (Kotlin, minSdk 30)

Phone-as-receiver for the USB-C -> HDMI mirroring use case. Speaks the
existing signaling + RTP wire protocol, so the C++ sender needs no
changes. AGP 9 built-in Kotlin (no separate plugin), no androidx.

- rtp/: RtpHeader, RtpPacket, JitterBuffer (16 pkt / 60 ms, straggler
  discard), H264Depacketizer (single-NAL + FU-A, Annex-B out) — JVM tests
- signaling/: newline-JSON server (offer -> answer, PLI, 500 ms rate cap)
- decode/H264Decoder: MediaCodec -> Surface; size from in-band SPS (offer
  is 0x0); surface passed to configure(); releaseOutputBuffer(render)
- pipeline/ReceiverPipeline: NSD advertise (API-36 RegistrationListener
  + reflection fallback), UDP RTP, jitter, decode, PLI recovery
- ReceiverActivity: fullscreen TextureView letterboxed by sizing the
  view to the video aspect (C2 scales output to the surface)

Validated end-to-end on a Fairphone 6 (API 36): offer/answer, first
frame, letterbox fit, PLI on loss.
This commit is contained in:
2026-09-10 11:31:13 +02:00
parent 69a1cdac1d
commit 455ba6055d
25 changed files with 1635 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.gradle/
.kotlin/
build/
local.properties
.idea/
captures/
.cxx/
+32
View File
@@ -0,0 +1,32 @@
plugins {
id("com.android.application")
}
android {
namespace = "screen_cast"
compileSdk = 36
defaultConfig {
applicationId = "screen_cast.receiver"
minSdk = 30
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
// No release signing configured: the app is sideloaded as a debug build.
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- NsdService and all socket I/O require INTERNET. No other permissions:
no camera, no location, no network state. The screen is kept on, which
keeps Wi-Fi up. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="screencast"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:theme="@style/Theme.Screencast">
<activity
android:name=".ReceiverActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:stateNotNeeded="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,153 @@
package screen_cast
import android.app.Activity
import android.graphics.SurfaceTexture
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import java.net.Inet4Address
import java.net.NetworkInterface
import kotlin.math.min
import screen_cast.pipeline.ReceiverPipeline
/**
* Fullscreen receiver: a letterboxed TextureView plus a status overlay.
* The pipeline lives in the activity lifetime — created on demand, stopped
* when the app leaves the foreground, released on destroy.
*
* For the USB-C → HDMI use case the app only guarantees the screen is on,
* undimmed, landscape, and immersive; the display mirroring itself is the
* OS behavior of a DisplayPort-alt-mode port.
*/
class ReceiverActivity : Activity() {
companion object {
private const val TAG = "ReceiverActivity"
}
private val ui = Handler(Looper.getMainLooper())
private var pipeline: ReceiverPipeline? = null
private lateinit var videoView: TextureView
private lateinit var statusView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_receiver)
videoView = findViewById(R.id.video)
statusView = findViewById(R.id.status)
videoView.isOpaque = true
videoView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
pipeline?.attachSurface(Surface(surface))
fitVideo()
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
fitVideo()
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean = true
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
}
}
override fun onResume() {
super.onResume()
applyFullscreen()
ensurePipeline()
pipeline?.start()
}
override fun onPause() {
super.onPause()
pipeline?.stop()
}
override fun onDestroy() {
super.onDestroy()
pipeline?.stop()
pipeline = null
}
private fun ensurePipeline() {
if (pipeline != null) return
val pipeline = ReceiverPipeline(
context = applicationContext,
localIp = localIpv4(),
displaySize = {
val metrics = resources.displayMetrics
metrics.widthPixels to metrics.heightPixels
},
onStatus = { text -> ui.post { statusView.text = text } },
onFirstFrame = { ui.post { statusView.visibility = View.GONE } },
onVideoSize = { _, _ -> ui.post { fitVideo() } },
)
// The surface may already exist by the time the pipeline starts.
val surfaceTexture = videoView.surfaceTexture
if (surfaceTexture != null) {
pipeline.attachSurface(Surface(surfaceTexture))
}
this.pipeline = pipeline
}
/**
* Fit-within (letterbox) by sizing the TextureView to the video's aspect
* ratio, centered on the black window background. The C2 decoder scales
* its output to the Surface (android._video-scaling), so a surface with
* the video's aspect ratio renders 1:1 without distortion; a transform
* matrix on top would double-scale the already-stretched buffer.
*/
private fun fitVideo() {
val (videoWidth, videoHeight) = pipeline?.videoSize() ?: return
val parent = videoView.parent as? View ?: return
val parentWidth = parent.width
val parentHeight = parent.height
if (videoWidth <= 0 || videoHeight <= 0 || parentWidth <= 0 || parentHeight <= 0) return
val scale = min(parentWidth.toFloat() / videoWidth, parentHeight.toFloat() / videoHeight)
val width = (videoWidth * scale).toInt()
val height = (videoHeight * scale).toInt()
val params = videoView.layoutParams
if (params.width == width && params.height == height) return
params.width = width
params.height = height
if (params is android.widget.FrameLayout.LayoutParams) {
params.gravity = android.view.Gravity.CENTER
}
videoView.layoutParams = params
android.util.Log.i(TAG, "fitVideo: ${width}x$height in ${parentWidth}x$parentHeight")
}
private fun applyFullscreen() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val controller = window.insetsController ?: return
controller.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(
android.view.WindowInsets.Type.statusBars() or android.view.WindowInsets.Type.navigationBars(),
)
}
private fun localIpv4(): String {
return try {
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return "unknown"
for (iface in interfaces) {
if (!iface.isUp || iface.isLoopback) continue
for (address in iface.inetAddresses) {
if (address is Inet4Address && !address.isLoopbackAddress) {
return address.hostAddress ?: "unknown"
}
}
}
"unknown"
} catch (e: Exception) {
"unknown"
}
}
}
@@ -0,0 +1,136 @@
package screen_cast.decode
import android.media.MediaCodec
import android.media.MediaFormat
import android.view.Surface
/**
* MediaCodec H.264 decoder that renders directly onto a Surface (no CPU
* pixels). The stream is self-describing: the sender repeats SPS/PPS
* in-band at every keyframe, so no out-of-band codec data is needed.
*/
class H264Decoder {
companion object {
// The C++ sender's VBV bounds keyframes to ~2 frame periods of bytes;
// 8 MiB is far beyond anything the negotiated rates can produce.
private const val MAX_INPUT_SIZE = 8 * 1024 * 1024
}
private var codec: MediaCodec? = null
private var configured = false
@Volatile
private var renderSurface: Surface? = null
/** Creates and configures the decoder. Width/height of 0 = unknown (the bitstream decides). */
@Synchronized
fun configure(width: Int, height: Int) {
if (configured) return
// 0x0 (the sender's offer) means the size is unknown: the Qualcomm
// C2 AVC decoder requires a concrete size at configure() and
// reconfigures from the in-band SPS of the first keyframe (the
// standard adaptive-resolution pattern).
val format = if (width > 0 && height > 0) {
MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height)
} else {
MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 320, 240)
}
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_INPUT_SIZE)
codec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC).also { c ->
// The surface must be passed to configure(); setOutputSurface()
// is only legal before configuration.
c.configure(format, renderSurface, null, 0)
c.start()
}
configured = true
}
/** Points the decoder at a (possibly new) render surface. */
@Synchronized
fun attachSurface(surface: Surface) {
renderSurface = surface
codec?.setOutputSurface(surface)
}
/** Queues one access unit. [presentationUs] must be monotonic (the RTP timestamp). */
fun feed(data: ByteArray, presentationUs: Long, isKeyFrame: Boolean) {
val c = codec ?: throw IllegalStateException("decoder not configured")
val index = c.dequeueInputBuffer(10_000)
if (index < 0) return
val buffer = c.getInputBuffer(index) ?: return
buffer.clear()
buffer.put(data)
val flags = if (isKeyFrame) MediaCodec.BUFFER_FLAG_SYNC_FRAME else 0
c.queueInputBuffer(index, 0, data.size, presentationUs, flags)
}
/**
* Non-blocking drain of available output frames; in surface mode each
* released buffer is rendered to the output surface by the codec.
* @throws IllegalStateException when the decoder signals a fatal error;
* the caller should flush() and request a keyframe.
*/
fun drain() {
val c = codec ?: return
val info = MediaCodec.BufferInfo()
while (true) {
val index = c.dequeueOutputBuffer(info, 0)
when {
index >= 0 -> {
// releaseOutputBuffer(render=true) is what puts the
// frame on the surface (and frees the pool slot).
c.releaseOutputBuffer(index, renderSurface != null)
}
index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
// The codec parsed the SPS size; outputFormat is ready.
android.util.Log.i(
"H264Decoder", "output format: " +
c.outputFormat.getInteger(MediaFormat.KEY_WIDTH) +
"x" +
c.outputFormat.getInteger(MediaFormat.KEY_HEIGHT)
)
}
index == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED -> Unit
else -> break // no more frames right now
}
}
}
/** Resets decoder state; the next keyframe re-primes it (in-band SPS/PPS). */
fun flush() {
val c = codec ?: return
try {
c.flush()
} catch (e: Exception) {
// A codec in an error state may refuse the flush; the caller
// follows up with a keyframe request either way.
}
}
/** The decoded resolution once the first keyframe has configured the codec. */
@Synchronized
fun outputSize(): Pair<Int, Int>? {
val c = codec ?: return null
return try {
val format = c.outputFormat
val width = format.getInteger(MediaFormat.KEY_WIDTH)
val height = format.getInteger(MediaFormat.KEY_HEIGHT)
if (width > 0 && height > 0) width to height else null
} catch (e: Exception) {
null
}
}
@Synchronized
fun release() {
val c = codec ?: return
try {
c.stop()
} catch (e: Exception) {
// ignore
}
c.release()
codec = null
configured = false
renderSurface = null
}
}
@@ -0,0 +1,341 @@
package screen_cast.pipeline
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.view.Surface
import screen_cast.decode.H264Decoder
import screen_cast.rtp.H264Depacketizer
import screen_cast.rtp.JitterBuffer
import screen_cast.rtp.RtpPacket
import screen_cast.signaling.SessionAnswer
import screen_cast.signaling.SessionOffer
import screen_cast.signaling.SessionPli
import screen_cast.signaling.SignalingServer
import java.lang.reflect.Proxy
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.net.SocketException
import java.util.concurrent.atomic.AtomicBoolean
/**
* The receiver pipeline, mirroring the C++ ReceiverPipeline:
*
* NSD advertise + signaling server (offer → answer)
* UDP RTP → jitter buffer → depacketize → MediaCodec → Surface
*
* Recovery matches the C++ receiver: a damaged frame is dropped and a PLI
* (rate-limited to one per 500 ms) asks the sender for a keyframe.
*/
class ReceiverPipeline(
private val context: Context,
private val localIp: String,
private val displaySize: () -> Pair<Int, Int>,
private val onStatus: (String) -> Unit,
private val onFirstFrame: () -> Unit,
private val onVideoSize: (Int, Int) -> Unit,
) {
companion object {
private const val TAG = "ReceiverPipeline"
private const val SERVICE_NAME = "screencast"
private const val SERVICE_TYPE = "_screencast._tcp"
private const val DESIRED_UDP_PORT = 5004
private const val DESIRED_SIGNALING_PORT = 5005
private const val PLI_MIN_INTERVAL_MS = 500L
}
private val nsdManager: NsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
private val decoderLock = Any()
@Volatile private var running = false
private var udpSocket: DatagramSocket? = null
private var udpPort = 0
private var signalingPort = 0
private var signaling: SignalingServer? = null
private var readerThread: Thread? = null
private var nsdInfo: NsdServiceInfo? = null
@Volatile private var registrationListener: NsdManager.RegistrationListener? = null
@Volatile private var legacyNsdListener: Any? = null
@Volatile private var decoder: H264Decoder? = null
@Volatile private var depacketizer = H264Depacketizer()
private val jitter = JitterBuffer()
@Volatile private var currentSurface: Surface? = null
@Volatile private var activeSession = ""
private val firstFrameSeen = AtomicBoolean(false)
private var lastPliAtMs = 0L
@Volatile private var videoWidth = 0
@Volatile private var videoHeight = 0
/** The current decoded resolution (0, 0 until the first keyframe). */
fun videoSize(): Pair<Int, Int> = videoWidth to videoHeight
/** Binds the ports, advertises the service, and starts reading RTP. */
@Synchronized
fun start() {
if (running) return
try {
// Plain DatagramSocket: consistent semantics across platforms
// (Android's DatagramChannel.receive() returns a SocketAddress,
// not the byte count). Port 5004 when free; otherwise ephemeral.
val socket = try {
DatagramSocket(InetSocketAddress(DESIRED_UDP_PORT))
} catch (e: Exception) {
DatagramSocket()
}
udpPort = socket.localPort
udpSocket = socket
} catch (e: Exception) {
onStatus("Failed to bind the media port: ${e.message}")
return
}
val server = SignalingServer(
onOffer = { offer -> onOffer(offer) },
onPli = { /* the receiver never receives PLIs */ },
)
try {
signalingPort = server.start(DESIRED_SIGNALING_PORT)
signaling = server
} catch (e: Exception) {
onStatus("Failed to start signaling: ${e.message}")
try {
udpSocket?.close()
} catch (ignored: Exception) {
}
return
}
running = true
readerThread = Thread({ readLoop() }, "rtp-reader").also { it.start() }
advertiseNsd(signalingPort)
onStatus(
"Listening on $localIp (media :$udpPort, signaling :$signalingPort)\n" +
"Waiting for a sender… (fall back to: screencast --send --peer $localIp:$signalingPort)",
)
}
/** Points the decoder (current or future) at a render surface. */
fun attachSurface(surface: Surface) {
currentSurface = surface
synchronized(decoderLock) { decoder?.attachSurface(surface) }
}
/** Stops listening; the pipeline can be started again. */
@Synchronized
fun stop() {
if (!running) return
running = false
activeSession = ""
try {
udpSocket?.close() // unblocks the reader's receive()
} catch (ignored: Exception) {
}
udpSocket = null
readerThread?.join(1000)
readerThread = null
signaling?.close()
signaling = null
unregisterNsd()
synchronized(decoderLock) {
decoder?.release()
decoder = null
}
firstFrameSeen.set(false)
onStatus("Stopped")
}
private fun onOffer(offer: SessionOffer) {
if (offer.codec != "h264") {
onStatus("Unsupported codec: ${offer.codec}")
return
}
// The offer's width/height are informational (the C++ sender leaves
// them 0); the bitstream carries SPS/PPS at every keyframe.
val fresh = H264Decoder()
val surface = currentSurface
var ok = true
synchronized(decoderLock) {
decoder?.release()
try {
// Surface first: configure() needs it before the codec starts.
surface?.let { fresh.attachSurface(it) }
fresh.configure(offer.width, offer.height)
decoder = fresh
} catch (e: Exception) {
ok = false
decoder = null
}
}
if (!ok) {
fresh.release()
onStatus("Could not start the decoder")
return
}
// New session: pristine reassembly state.
depacketizer = H264Depacketizer()
jitter.clear()
firstFrameSeen.set(false)
videoWidth = 0
videoHeight = 0
activeSession = offer.sessionId
android.util.Log.i(TAG, "offer: session=${offer.sessionId} ${offer.width}x${offer.height} @${offer.frameRateNum}/${offer.frameRateDen}")
val (displayWidth, displayHeight) = displaySize()
signaling?.send(
SessionAnswer(
sessionId = offer.sessionId,
rtpAddress = "", // the sender targets the address of its own signaling connection
rtpPort = udpPort,
displayWidth = displayWidth,
displayHeight = displayHeight,
),
)
onStatus("Session ${offer.sessionId} negotiated — waiting for the first frame…")
}
private fun readLoop() {
val socket = udpSocket ?: return
val buffer = ByteArray(2048) // MTU 1200 + headroom
val datagram = DatagramPacket(buffer, buffer.size)
while (running) {
try {
socket.receive(datagram)
} catch (e: SocketException) {
break // socket closed
} catch (e: Exception) {
continue
}
val received = datagram.length
if (received <= 0) continue
val bytes = if (received == buffer.size) buffer.copyOf() else buffer.copyOf(received)
val packet = RtpPacket.parse(bytes) ?: continue
for (released in jitter.push(packet)) {
handleDepacketized(released)
}
}
}
private fun handleDepacketized(packet: RtpPacket) {
val result = depacketizer.depacketize(packet)
val accessUnit = result.accessUnit
if (accessUnit != null) {
val activeDecoder = synchronized(decoderLock) { decoder }
if (activeDecoder == null) return
try {
val presentationUs = packet.header.timestamp.toLong() and 0xFFFFFFFFL
activeDecoder.feed(accessUnit, presentationUs, result.isKeyFrame)
activeDecoder.drain()
if (firstFrameSeen.compareAndSet(false, true)) {
onFirstFrame()
}
updateVideoSize()
} catch (e: Exception) {
onStatus("Decoder error (${e.message}) — requesting a keyframe…")
activeDecoder.flush()
requestPli()
}
}
if (result.frameDropped) {
requestPli()
}
}
private fun updateVideoSize() {
val size = synchronized(decoderLock) { decoder?.outputSize() } ?: return
if (size != null && (size.first != videoWidth || size.second != videoHeight)) {
videoWidth = size.first
videoHeight = size.second
android.util.Log.i(TAG, "video size: ${size.first}x${size.second}")
onVideoSize(size.first, size.second)
}
}
// Rate-limited keyframe request (single-threaded access from the reader).
private fun requestPli() {
val session = activeSession
if (session.isEmpty()) return
val now = System.currentTimeMillis()
if (now - lastPliAtMs < PLI_MIN_INTERVAL_MS) return
lastPliAtMs = now
android.util.Log.i(TAG, "PLI requested for session $session")
signaling?.send(screen_cast.signaling.SessionPli(session))
}
private fun advertiseNsd(port: Int) {
val info = NsdServiceInfo().apply {
setServiceName(SERVICE_NAME)
setServiceType(SERVICE_TYPE)
setPort(port)
}
nsdInfo = info
// The NSD registration API was replaced in API 36 (ResolutionListener
// removed). Try the new API first, then the legacy one via reflection
// so the same build works on both.
try {
val listener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
// Registered: the sender's mDNS browser should see it now.
}
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) = Unit
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
onStatus("mDNS registration failed — reach this receiver with --peer $localIp:$port")
}
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
}
registrationListener = listener
nsdManager.registerService(info, 0, listener)
} catch (e: Throwable) {
try {
registerNsdLegacy(info, port)
} catch (e2: Throwable) {
onStatus("mDNS unavailable (${e2.message}) — reach this receiver with --peer $localIp:$port")
}
}
}
/** Pre-API-36 registration API (removed from the API 36 SDK; reflection). */
private fun registerNsdLegacy(info: NsdServiceInfo, port: Int) {
val listenerClass = Class.forName("android.net.nsd.NsdManager\$ResolutionListener")
val proxy = Proxy.newProxyInstance(
listenerClass.classLoader,
arrayOf(listenerClass),
) { _, method, _ ->
if (method.name == "onResolutionFailed") {
onStatus("mDNS registration failed — reach this receiver with --peer $localIp:$port")
}
null
}
legacyNsdListener = proxy
NsdManager::class.java
.getMethod("registerService", NsdServiceInfo::class.java, Int::class.javaPrimitiveType, listenerClass)
.invoke(nsdManager, info, 0, proxy)
}
private fun unregisterNsd() {
val newListener = registrationListener
val legacyListener = legacyNsdListener
val info = nsdInfo
try {
when {
newListener != null -> nsdManager.unregisterService(newListener)
legacyListener != null && info != null -> NsdManager::class.java
.getMethod("unregisterService", NsdServiceInfo::class.java)
.invoke(nsdManager, info)
else -> return
}
} catch (ignored: Throwable) {
// already unregistered
}
registrationListener = null
legacyNsdListener = null
nsdInfo = null
}
}
@@ -0,0 +1,175 @@
package screen_cast.rtp
/** Result of feeding one packet to the depacketizer. */
data class DepacketizeResult(
/** Completed access unit (Annex-B with 3-byte start codes) when the frame closed undamaged. */
val accessUnit: ByteArray? = null,
/** True when this call discarded a frame as damaged (packet loss or unsupported packetization). */
val frameDropped: Boolean = false,
/** True when the completed access unit carries SPS/PPS (a keyframe). */
val isKeyFrame: Boolean = false,
)
/**
* Reassembles RFC 6184 packet streams (single NAL unit packets and FU-A)
* into Annex-B access units. Packets must arrive in order; frames damaged
* by sequence gaps or missing fragments are reported via DepacketizeResult.
*
* Mirrors the C++ `H264Depacketizer` (same state machine and start codes).
*/
class H264Depacketizer {
companion object {
private const val FU_A = 28
}
private var lastSequenceNumber: Int? = null
private var frameStarted = false
private var frameDamaged = false
private var frameTimestamp = 0
private val accessUnit = ArrayList<Int>()
private var fuActive = false
private val fuNal = ArrayList<Int>()
/** Feed one packet (in sequence order, from the jitter buffer). */
fun depacketize(packet: RtpPacket): DepacketizeResult {
var result = DepacketizeResult()
// Track sequence continuity: a gap means packets were lost.
lastSequenceNumber?.let { last ->
val expected = (last + 1) and 0xFFFF
if (packet.header.sequenceNumber != expected) {
fuActive = false
fuNal.clear()
if (frameStarted) {
frameDamaged = true
}
}
}
lastSequenceNumber = packet.header.sequenceNumber
// A timestamp change without a closing marker means the previous frame
// lost its tail and can no longer be recovered.
if (frameStarted && packet.header.timestamp != frameTimestamp) {
dropFrame()
result = result.copy(frameDropped = true)
}
if (!frameStarted) {
frameStarted = true
frameDamaged = false
frameTimestamp = packet.header.timestamp
accessUnit.clear()
}
val payload = packet.payload
if (payload.isNotEmpty()) {
val type = payload[0].toInt() and 0x1F
when {
type in 1..23 -> {
// Single NAL unit packet.
if (fuActive) {
// The previous fragmented NAL never received its end packet.
frameDamaged = true
fuActive = false
fuNal.clear()
}
appendStartCode()
payload.forEach { accessUnit.add(it.toInt() and 0xFF) }
}
type == FU_A -> {
if (payload.size < 2) {
frameDamaged = true
} else {
val fuHeader = payload[1].toInt() and 0xFF
val start = fuHeader and 0x80 != 0
val end = fuHeader and 0x40 != 0
val fragment = payload.copyOfRange(2, payload.size)
when {
start -> {
if (fuActive) {
// The previous fragmented NAL lost its end packet.
frameDamaged = true
}
fuActive = true
fuNal.clear()
// The FU indicator keeps the original NAL's F bit (0) and NRI,
// and declares type 28; the FU header carries S/E plus the real type.
fuNal.add((payload[0].toInt() and 0xE0) or (fuHeader and 0x1F))
fragment.forEach { fuNal.add(it.toInt() and 0xFF) }
}
!fuActive -> {
// Continuation without a start: the head of the NAL is lost.
frameDamaged = true
}
else -> {
fragment.forEach { fuNal.add(it.toInt() and 0xFF) }
if (end) {
appendStartCode()
accessUnit.addAll(fuNal)
fuActive = false
fuNal.clear()
}
}
}
}
}
else -> {
// Unsupported packetization mode (STAP-A, MTAP, FU-B): the frame
// cannot be reconstructed.
frameDamaged = true
}
}
}
if (!packet.header.marker) {
return result
}
if (fuActive) {
// The marker arrived while a NAL was still fragmented.
frameDamaged = true
fuActive = false
fuNal.clear()
}
if (!frameDamaged && accessUnit.isNotEmpty()) {
val unit = ByteArray(accessUnit.size) { accessUnit[it].toByte() }
result = result.copy(accessUnit = unit, isKeyFrame = containsParameterSets(unit))
} else {
// The frame that just ended is unusable.
result = result.copy(frameDropped = true)
}
dropFrame()
return result
}
private fun appendStartCode() {
accessUnit.add(0)
accessUnit.add(0)
accessUnit.add(1)
}
/** The sender repeats SPS/PPS in-band at every keyframe; sniff for NAL types 7/8. */
private fun containsParameterSets(unit: ByteArray): Boolean {
for (i in 0..unit.size - 4) {
if (unit[i] == 0.toByte() && unit[i + 1] == 0.toByte() && unit[i + 2] == 1.toByte()) {
val nalType = unit[i + 3].toInt() and 0x1F
if (nalType == 7 || nalType == 8) {
return true
}
}
}
return false
}
private fun dropFrame() {
frameStarted = false
frameDamaged = false
accessUnit.clear()
fuActive = false
fuNal.clear()
}
}
@@ -0,0 +1,71 @@
package screen_cast.rtp
import java.util.TreeMap
import java.util.concurrent.TimeUnit
/**
* Reorders RTP packets by sequence number before depacketization so that a
* reordering link (Wi-Fi) is not read as loss. Delivery stays in order;
* only aged-out or overflowing buffers release out of order, which the
* downstream gap detection still handles for genuine loss.
*
* Mirrors the C++ `RtpJitterBuffer` (same defaults and semantics).
*/
class JitterBuffer(
private val maxDepth: Int = 16,
private val maxDelayMs: Long = 60,
) {
private class BufferEntry(val timeNanos: Long, val packet: RtpPacket)
private val lock = Any()
private val buffer = TreeMap<Int, BufferEntry>()
private var nextExpected: Int? = null
/** Insert one packet and return the packets now ready for in-order delivery. */
fun push(packet: RtpPacket): List<RtpPacket> = synchronized(lock) {
val released = ArrayList<RtpPacket>()
val sequence = packet.header.sequenceNumber
val now = System.nanoTime()
val expected0 = nextExpected ?: sequence.also { nextExpected = it }
// Serial-number comparison: a distance >= 32768 means the packet is
// older than what we already delivered (duplicate or straggler).
val distance = (sequence - expected0 + 65536) % 65536
if (distance < 32768) {
buffer[sequence] = BufferEntry(now, packet)
// Release the consecutive run from the expected sequence.
var expected = expected0
while (true) {
val entry = buffer[expected] ?: break
released.add(entry.packet)
buffer.remove(expected)
expected = (expected + 1) and 0xFFFF
}
nextExpected = expected
// A missing packet stalls the run: age out the backlog (or bound
// the buffer) and release what is there in order, so genuine loss
// reaches the depacketizer's gap detection rather than blocking.
if (buffer.isNotEmpty()) {
val head = buffer.firstEntry().value
val headAgeMs = TimeUnit.NANOSECONDS.toMillis(now - head.timeNanos)
if (headAgeMs > maxDelayMs || buffer.size > maxDepth) {
released.addAll(buffer.values.map { it.packet })
nextExpected = (buffer.lastKey() + 1) and 0xFFFF
buffer.clear()
}
}
}
released
}
/** Discard everything still buffered. */
fun clear() {
synchronized(lock) {
buffer.clear()
nextExpected = null
}
}
}
@@ -0,0 +1,55 @@
package screen_cast.rtp
/** Minimal RTP header (RFC 3550) without extensions. */
data class RtpHeader(
val version: Int = 2,
val padding: Boolean = false,
val extension: Boolean = false,
val csrcCount: Int = 0,
val marker: Boolean = false,
val payloadType: Int = 96,
val sequenceNumber: Int = 0,
val timestamp: Int = 0,
val ssrc: Int = 0,
) {
/** Serializes the bare 12-byte header; requires a version-2, extension-less header. */
fun serialize(): ByteArray {
val out = ByteArray(12)
out[0] = (((version and 0x0F) shl 6) or (if (padding) 0x20 else 0) or (if (extension) 0x10 else 0) or (csrcCount and 0x0F)).toByte()
out[1] = ((if (marker) 0x80 else 0) or (payloadType and 0x7F)).toByte()
out[2] = (sequenceNumber ushr 8).toByte()
out[3] = (sequenceNumber and 0xFF).toByte()
out[4] = (timestamp ushr 24).toByte()
out[5] = (timestamp ushr 16).toByte()
out[6] = (timestamp ushr 8).toByte()
out[7] = (timestamp and 0xFF).toByte()
out[8] = (ssrc ushr 24).toByte()
out[9] = (ssrc ushr 16).toByte()
out[10] = (ssrc ushr 8).toByte()
out[11] = (ssrc and 0xFF).toByte()
return out
}
companion object {
fun parse(input: ByteArray): RtpHeader? {
if (input.size < 12) return null
val b0 = input[0].toInt() and 0xFF
val b1 = input[1].toInt() and 0xFF
val version = b0 ushr 6
if (version != 2) return null
return RtpHeader(
version = version,
padding = b0 and 0x20 != 0,
extension = b0 and 0x10 != 0,
csrcCount = b0 and 0x0F,
marker = b1 and 0x80 != 0,
payloadType = b1 and 0x7F,
sequenceNumber = ((input[2].toInt() and 0xFF) shl 8) or (input[3].toInt() and 0xFF),
timestamp = ((input[4].toInt() and 0xFF) shl 24) or ((input[5].toInt() and 0xFF) shl 16) or
((input[6].toInt() and 0xFF) shl 8) or (input[7].toInt() and 0xFF),
ssrc = ((input[8].toInt() and 0xFF) shl 24) or ((input[9].toInt() and 0xFF) shl 16) or
((input[10].toInt() and 0xFF) shl 8) or (input[11].toInt() and 0xFF),
)
}
}
}
@@ -0,0 +1,36 @@
package screen_cast.rtp
/** An RTP packet: 12-byte base header (plus optional CSRC/extension) and payload. */
class RtpPacket(val header: RtpHeader, val payload: ByteArray) {
companion object {
/**
* Parses a full RTP datagram. Honors CSRC lists, one-level extension
* headers, and RFC 3550 padding, mirroring the C++ receiver.
*/
fun parse(input: ByteArray): RtpPacket? {
if (input.size < 12) return null
val header = RtpHeader.parse(input) ?: return null
var offset = 12 + header.csrcCount * 4
if (input.size < offset) return null
if (header.extension) {
if (input.size < offset + 4) return null
val extensionWords =
((input[offset + 2].toInt() and 0xFF) shl 8) or (input[offset + 3].toInt() and 0xFF)
offset += 4 + extensionWords * 4
if (input.size < offset) return null
}
var payloadSize = input.size - offset
if (header.padding) {
// RFC 3550: the last byte holds the padding size, including itself.
if (payloadSize == 0) return null
val paddingSize = input[input.size - 1].toInt() and 0xFF
if (paddingSize == 0 || paddingSize > payloadSize) return null
payloadSize -= paddingSize
}
return RtpPacket(header, input.copyOfRange(offset, offset + payloadSize))
}
}
}
@@ -0,0 +1,100 @@
package screen_cast.signaling
import org.json.JSONObject
// JSON wire format shared with the C++ implementation: one JSON object per
// newline-terminated TCP line (offer / answer / pli).
data class SessionOffer(
val sessionId: String,
val codec: String,
val width: Int,
val height: Int,
val frameRateNum: Int,
val frameRateDen: Int,
val rtpAddress: String,
val rtpPort: Int,
) : SignalingMessage
data class SessionAnswer(
val sessionId: String,
val rtpAddress: String,
val rtpPort: Int,
val displayWidth: Int,
val displayHeight: Int,
) : SignalingMessage
data class SessionPli(val sessionId: String) : SignalingMessage
sealed interface SignalingMessage {
companion object {
private const val MAX_MESSAGE_BYTES = 64 * 1024
fun parse(line: String): SignalingMessage? {
if (line.length > MAX_MESSAGE_BYTES) return null
val json = try {
JSONObject(line)
} catch (e: Exception) {
return null
}
when (val type = json.optString("type")) {
"offer" ->
return SessionOffer(
sessionId = json.optString("session_id"),
codec = json.optString("codec"),
width = json.optInt("width"),
height = json.optInt("height"),
frameRateNum = json.optInt("frame_rate_num", 30),
frameRateDen = json.optInt("frame_rate_den", 1),
rtpAddress = json.optString("rtp_address"),
rtpPort = json.optInt("rtp_port"),
)
"answer" ->
return SessionAnswer(
sessionId = json.optString("session_id"),
rtpAddress = json.optString("rtp_address"),
rtpPort = json.optInt("rtp_port"),
displayWidth = json.optInt("display_width"),
displayHeight = json.optInt("display_height"),
)
"pli" -> return SessionPli(sessionId = json.optString("session_id"))
else -> return null
}
}
fun serialize(message: SignalingMessage): String {
val json = JSONObject()
when (message) {
is SessionOffer -> {
json.put("type", "offer")
json.put("session_id", message.sessionId)
json.put("codec", message.codec)
json.put("width", message.width)
json.put("height", message.height)
json.put("frame_rate_num", message.frameRateNum)
json.put("frame_rate_den", message.frameRateDen)
json.put("rtp_address", message.rtpAddress)
json.put("rtp_port", message.rtpPort)
}
is SessionAnswer -> {
json.put("type", "answer")
json.put("session_id", message.sessionId)
json.put("rtp_address", message.rtpAddress)
json.put("rtp_port", message.rtpPort)
json.put("display_width", message.displayWidth)
json.put("display_height", message.displayHeight)
}
is SessionPli -> {
json.put("type", "pli")
json.put("session_id", message.sessionId)
}
}
return json.toString() + "\n"
}
}
}
@@ -0,0 +1,127 @@
package screen_cast.signaling
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
/**
* Newline-delimited JSON signaling server (the receiver side). Keeps the
* most recent connection as its active peer, mirroring the C++ server:
* `onOffer` may answer synchronously (the sender blocks on the answer).
*/
class SignalingServer(
private val onOffer: (SessionOffer) -> Unit,
private val onPli: (SessionPli) -> Unit,
) {
private val server = ServerSocket()
private val peerLock = Any()
private var peerOut: OutputStream? = null
private var acceptThread: Thread? = null
private var readerThread: Thread? = null
@Volatile
private var running = false
/** Binds the port (SO_REUSEADDR) and starts accepting. Returns the bound port. */
fun start(port: Int): Int {
server.reuseAddress = true
server.bind(InetSocketAddress(port))
running = true
acceptThread = Thread({ acceptLoop() }, "signaling-accept")
acceptThread!!.start()
return server.localPort
}
fun send(message: SignalingMessage) {
val bytes = SignalingMessage.serialize(message).toByteArray(Charsets.UTF_8)
synchronized(peerLock) {
peerOut?.let { out ->
out.write(bytes)
out.flush()
}
}
}
fun close() {
running = false
try {
server.close()
} catch (e: Exception) {
// already closed
}
synchronized(peerLock) {
try {
peerOut?.close()
} catch (e: Exception) {
// peer already gone
}
peerOut = null
}
acceptThread?.join(500)
readerThread?.join(500)
}
private fun acceptLoop() {
while (running) {
val socket = try {
server.accept()
} catch (e: Exception) {
break // listening socket closed
}
synchronized(peerLock) {
try {
peerOut?.close()
} catch (e: Exception) {
// previous sender already gone
}
peerOut = socket.getOutputStream()
}
readerThread = Thread({ readLoop(socket) }, "signaling-reader").also { it.start() }
}
}
private fun readLoop(socket: Socket) {
val input = BufferedInputStream(socket.getInputStream())
val line = ByteArrayOutputStream()
val chunk = ByteArray(4096)
while (running) {
val read = try {
input.read(chunk)
} catch (e: Exception) {
break
}
if (read < 0) break
for (i in 0 until read) {
val b = chunk[i].toInt() and 0xFF
if (b == '\n'.code) {
val text = line.toString(Charsets.UTF_8.name())
line.reset()
if (text.isNotEmpty()) {
dispatch(text)
}
} else if (b != '\r'.code) {
if (line.size() < 64 * 1024) {
line.write(b)
} else {
line.reset() // hostile or broken peer: drop the oversized line
}
}
}
}
}
private fun dispatch(line: String) {
val message = SignalingMessage.parse(line) ?: return
try {
when (message) {
is SessionOffer -> onOffer(message)
is SessionPli -> onPli(message)
is SessionAnswer -> Unit // the receiver never receives answers
}
} catch (e: Exception) {
// A broken callback must not kill the reader thread.
}
}
}
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<TextureView
android:id="@+id/video"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_margin="16dp"
android:background="#66000000"
android:maxLines="3"
android:ellipsize="end"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:textColor="@android:color/white"
android:textSize="13sp"
tools:text="Listening… waiting for a sender"
xmlns:tools="http://schemas.android.com/tools" />
</FrameLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Screencast" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,138 @@
package screen_cast.rtp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class H264DepacketizerTest {
companion object {
private val START = byteArrayOf(0x00, 0x00, 0x01)
private fun packet(seq: Int, ts: Int, payload: ByteArray, marker: Boolean = false) =
RtpPacket(
RtpHeader(sequenceNumber = seq, timestamp = ts, marker = marker),
payload,
)
}
@Test
fun single_nal_two_packets() {
val depacketizer = H264Depacketizer()
// SPS (type 7) then a slice (type 5), closed by the marker.
val first = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x67, 0xAA.toByte(), 0xBB.toByte())))
assertNull(first.accessUnit)
val second = depacketizer.depacketize(packet(2, 100, byteArrayOf(0x41, 0x01, 0x02), marker = true))
assertTrue(
second.accessUnit
?.contentEquals(START + byteArrayOf(0x67, 0xAA.toByte(), 0xBB.toByte()) + START + byteArrayOf(0x41, 0x01, 0x02)) == true,
)
assertTrue(second.isKeyFrame)
assertFalse(second.frameDropped)
}
@Test
fun fu_a_reassembly() {
val depacketizer = H264Depacketizer()
// NAL: header 0x41 (type 1, NRI 2) + payload 0x11 0x22 0x33 0x44.
// FU indicator = (0x41 & 0xE0) | 28 = 0x5C; the depacketizer rebuilds
// the NAL header from indicator-NRI | FU-type, so the FU header must
// carry the original type (1).
val indicator = 0x5C.toByte()
val start = packet(1, 100, byteArrayOf(indicator, 0x81.toByte(), 0x11))
val mid = packet(2, 100, byteArrayOf(indicator, 0x01, 0x22))
val last = packet(3, 100, byteArrayOf(indicator, 0x41, 0x33, 0x44), marker = true)
assertNull(depacketizer.depacketize(start).accessUnit)
assertNull(depacketizer.depacketize(mid).accessUnit)
val done = depacketizer.depacketize(last)
assertTrue(done.accessUnit?.contentEquals(START + byteArrayOf(0x41, 0x11, 0x22, 0x33, 0x44)) == true)
assertFalse(done.frameDropped)
}
@Test
fun drops_gapped_frames() {
val depacketizer = H264Depacketizer()
val first = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x41, 0x01)))
assertNull(first.accessUnit)
// seq 2 is missing; the frame must be reported dropped, not delivered.
val tail = depacketizer.depacketize(packet(3, 100, byteArrayOf(0x41, 0x02), marker = true))
assertNull(tail.accessUnit)
assertTrue(tail.frameDropped)
}
@Test
fun separate_frames_by_marker() {
val depacketizer = H264Depacketizer()
val au1 = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x41, 0xAA.toByte()), marker = true))
assertTrue(au1.accessUnit?.contentEquals(START + byteArrayOf(0x41, 0xAA.toByte())) == true)
val au2 = depacketizer.depacketize(packet(2, 200, byteArrayOf(0x41, 0xBB.toByte()), marker = true))
assertTrue(au2.accessUnit?.contentEquals(START + byteArrayOf(0x41, 0xBB.toByte())) == true)
}
@Test
fun drops_fu_without_start() {
val depacketizer = H264Depacketizer()
// Continuation (no S bit) without any start packet.
val result = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x7C.toByte(), 0x41.toByte(), 0x11), marker = true))
assertNull(result.accessUnit)
assertTrue(result.frameDropped)
}
@Test
fun drops_still_fragmented_at_marker() {
val depacketizer = H264Depacketizer()
val start = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x7C.toByte(), 0x81.toByte(), 0x11)))
assertNull(start.accessUnit)
// Marker arrives while the FU-A NAL is still open.
val result = depacketizer.depacketize(packet(2, 100, byteArrayOf(0x41, 0x01), marker = true))
assertNull(result.accessUnit)
assertTrue(result.frameDropped)
}
@Test
fun drops_unsupported_packetization() {
val depacketizer = H264Depacketizer()
val stapA = 24.toByte() // STAP-A
val result = depacketizer.depacketize(
packet(1, 100, byteArrayOf(stapA, 0x00, 0x05, 0x41, 0x01), marker = true),
)
assertNull(result.accessUnit)
assertTrue(result.frameDropped)
}
@Test
fun drops_timestamp_change_without_marker() {
val depacketizer = H264Depacketizer()
val first = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x41, 0x01)))
assertNull(first.accessUnit)
// The stale frame is reported dropped, but this packet starts (and
// closes) the next frame — matching the C++ depacketizer.
val result = depacketizer.depacketize(packet(2, 200, byteArrayOf(0x41, 0x02), marker = true))
assertTrue(result.accessUnit?.contentEquals(START + byteArrayOf(0x41, 0x02)) == true)
assertTrue(result.frameDropped)
}
@Test
fun keyframe_detection_requires_parameter_sets() {
val depacketizer = H264Depacketizer()
val plain = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x41, 0x01), marker = true))
assertFalse(plain.isKeyFrame)
val depacketizer2 = H264Depacketizer()
val withSps = depacketizer2.depacketize(
packet(1, 100, byteArrayOf(0x67, 0xAA.toByte(), 0x88.toByte(), 0x68, 0xBB.toByte(), 0x41, 0x01), marker = true),
)
assertTrue(withSps.isKeyFrame)
}
@Test
fun drops_short_fu_packets() {
val depacketizer = H264Depacketizer()
// FU-A packet without its FU header byte.
val result = depacketizer.depacketize(packet(1, 100, byteArrayOf(0x7C.toByte()), marker = true))
assertNull(result.accessUnit)
assertTrue(result.frameDropped)
}
}
@@ -0,0 +1,65 @@
package screen_cast.rtp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class JitterBufferTest {
private fun packet(seq: Int, ts: Int = 100) =
RtpPacket(RtpHeader(sequenceNumber = seq, timestamp = ts), byteArrayOf(seq.toByte()))
@Test
fun in_order_releases_immediately() {
val jitter = JitterBuffer()
assertEquals(listOf(1), jitter.push(packet(1)).map { it.header.sequenceNumber })
assertEquals(listOf(2), jitter.push(packet(2)).map { it.header.sequenceNumber })
assertEquals(listOf(3), jitter.push(packet(3)).map { it.header.sequenceNumber })
}
@Test
fun reorders_out_of_order_packets() {
val jitter = JitterBuffer()
assertEquals(listOf(1), jitter.push(packet(1)).map { it.header.sequenceNumber })
assertTrue(jitter.push(packet(3)).isEmpty())
val released = jitter.push(packet(2)).map { it.header.sequenceNumber }
assertEquals(listOf(2, 3), released)
}
@Test
fun overflow_releases_in_order_and_advances() {
val jitter = JitterBuffer(maxDepth = 4)
assertTrue(jitter.push(packet(1)).map { it.header.sequenceNumber } == listOf(1))
// seq 2 is lost; 3..6 stay buffered (within the depth bound).
for (seq in 3..6) {
assertTrue(jitter.push(packet(seq)).isEmpty())
}
// seq 7 overflows the buffer: 3..7 flush in order.
assertEquals(listOf(3, 4, 5, 6, 7), jitter.push(packet(7)).map { it.header.sequenceNumber })
// Delivery continues in order afterwards.
assertEquals(listOf(8), jitter.push(packet(8)).map { it.header.sequenceNumber })
assertEquals(listOf(9), jitter.push(packet(9)).map { it.header.sequenceNumber })
}
@Test
fun discards_stragglers() {
val jitter = JitterBuffer(maxDepth = 4)
jitter.push(packet(1))
for (seq in 3..8) {
jitter.push(packet(seq))
}
assertTrue(jitter.push(packet(9)).isNotEmpty())
// seq 4 is now far behind the expected sequence: discarded, not delivered.
assertTrue(jitter.push(packet(4)).isEmpty())
// In-order delivery continues from 10.
assertEquals(listOf(10), jitter.push(packet(10)).map { it.header.sequenceNumber })
}
@Test
fun clear_resets_state() {
val jitter = JitterBuffer()
jitter.push(packet(5))
jitter.clear()
// A completely different sequence now starts fresh.
assertEquals(listOf(100), jitter.push(packet(100)).map { it.header.sequenceNumber })
}
}
@@ -0,0 +1,112 @@
package screen_cast.rtp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class RtpHeaderTest {
@Test
fun roundtrip() {
val header = RtpHeader(
version = 2,
padding = false,
extension = false,
csrcCount = 0,
marker = true,
payloadType = 96,
sequenceNumber = 0xABCD,
timestamp = 0xDEADBEEF.toInt(),
ssrc = 0x12345678,
)
val wire = header.serialize()
assertEquals(12, wire.size)
val parsed = RtpHeader.parse(wire)
assertEquals(header, parsed)
}
@Test
fun rejects_bad_version() {
val wire = RtpHeader().serialize().copyOf()
wire[0] = (wire[0].toInt() and 0x3F or (1 shl 6)).toByte()
assertNull(RtpHeader.parse(wire))
}
@Test
fun rejects_short_input() {
val header = RtpHeader()
assertNull(RtpHeader.parse(header.serialize().copyOfRange(0, 11)))
assertNull(RtpHeader.parse(ByteArray(0)))
}
@Test
fun preserves_flags() {
val header = RtpHeader(padding = true, csrcCount = 2, marker = true, payloadType = 63)
val parsed = RtpHeader.parse(header.serialize())
assertEquals(true, parsed?.padding)
assertEquals(2, parsed?.csrcCount)
assertEquals(true, parsed?.marker)
assertEquals(63, parsed?.payloadType)
}
}
class RtpPacketTest {
private fun header(seq: Int, marker: Boolean = false) =
RtpHeader(sequenceNumber = seq, payloadType = 96, marker = marker)
@Test
fun roundtrip_with_payload() {
val packet = RtpPacket(header(seq = 7), byteArrayOf(0x11, 0x22, 0x33))
val wire = packet.header.serialize() + packet.payload
val parsed = RtpPacket.parse(wire)
assertEquals(header(seq = 7), parsed?.header)
assertTrue(parsed?.payload?.contentEquals(byteArrayOf(0x11, 0x22, 0x33)) == true)
}
@Test
fun skips_csrc_list() {
val header = RtpHeader(csrcCount = 1, sequenceNumber = 3)
val wire = header.serialize() + byteArrayOf(0x0A, 0x00, 0x00, 0x01) + byteArrayOf(0x99.toByte())
val parsed = RtpPacket.parse(wire)
assertEquals(1, parsed?.header?.csrcCount)
assertTrue(parsed?.payload?.contentEquals(byteArrayOf(0x99.toByte())) == true)
}
@Test
fun skips_extension_header() {
val header = RtpHeader(extension = true, sequenceNumber = 4)
// profile=0x0001, length=1 word, one word of data
val wire = header.serialize() +
byteArrayOf(0x00, 0x01, 0x00, 0x01, 0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) +
byteArrayOf(0x77)
val parsed = RtpPacket.parse(wire)
assertTrue(parsed?.payload?.contentEquals(byteArrayOf(0x77)) == true)
}
@Test
fun strips_padding() {
val header = RtpHeader(padding = true, sequenceNumber = 5)
// Payload byte, one padding zero, size byte (2 = padding incl. itself).
val wire = header.serialize() + byteArrayOf(0x55, 0x00, 0x02)
val parsed = RtpPacket.parse(wire)
assertTrue(parsed?.payload?.contentEquals(byteArrayOf(0x55)) == true)
}
@Test
fun rejects_truncated_csrc_and_extension() {
val csrc = RtpHeader(csrcCount = 1).serialize()
assertNull(RtpPacket.parse(csrc)) // 12 bytes, needs 16
val ext = RtpHeader(extension = true).serialize() + byteArrayOf(0x00, 0x01)
assertNull(RtpPacket.parse(ext)) // extension length field cut off
}
@Test
fun rejects_bad_padding() {
val zeroPad = RtpHeader(padding = true).serialize() + byteArrayOf(0x00)
assertNull(RtpPacket.parse(zeroPad))
val oversized = RtpHeader(padding = true).serialize() + byteArrayOf(0x00, 0x00, 0x05)
assertNull(RtpPacket.parse(oversized))
assertNull(RtpPacket.parse(ByteArray(11)))
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.4.0" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.caching=true
kotlin.code.style=official
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "screen_cast-android"
include(":app")