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:
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
captures/
|
||||||
|
.cxx/
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.4.0" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.caching=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "screen_cast-android"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user