From 455ba6055d70b3ce8743deef302e9b1d5920e528 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 10 Sep 2026 11:31:13 +0200 Subject: [PATCH] feat(android): native receiver app (Kotlin, minSdk 30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/.gitignore | 7 + android/app/build.gradle.kts | 32 ++ android/app/src/main/AndroidManifest.xml | 28 ++ .../kotlin/screen_cast/ReceiverActivity.kt | 153 ++++++++ .../kotlin/screen_cast/decode/H264Decoder.kt | 136 +++++++ .../screen_cast/pipeline/ReceiverPipeline.kt | 341 ++++++++++++++++++ .../screen_cast/rtp/H264Depacketizer.kt | 175 +++++++++ .../kotlin/screen_cast/rtp/JitterBuffer.kt | 71 ++++ .../main/kotlin/screen_cast/rtp/RtpHeader.kt | 55 +++ .../main/kotlin/screen_cast/rtp/RtpPacket.kt | 36 ++ .../screen_cast/signaling/SignalingMessage.kt | 100 +++++ .../screen_cast/signaling/SignalingServer.kt | 127 +++++++ .../src/main/res/layout/activity_receiver.xml | 27 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4494 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2776 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6355 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 10758 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 16174 bytes android/app/src/main/res/values/themes.xml | 9 + .../screen_cast/rtp/H264DepacketizerTest.kt | 138 +++++++ .../screen_cast/rtp/JitterBufferTest.kt | 65 ++++ .../kotlin/screen_cast/rtp/RtpHeaderTest.kt | 112 ++++++ android/build.gradle.kts | 3 + android/gradle.properties | 3 + android/settings.gradle.kts | 17 + 25 files changed, 1635 insertions(+) create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/screen_cast/ReceiverActivity.kt create mode 100644 android/app/src/main/kotlin/screen_cast/decode/H264Decoder.kt create mode 100644 android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt create mode 100644 android/app/src/main/kotlin/screen_cast/rtp/H264Depacketizer.kt create mode 100644 android/app/src/main/kotlin/screen_cast/rtp/JitterBuffer.kt create mode 100644 android/app/src/main/kotlin/screen_cast/rtp/RtpHeader.kt create mode 100644 android/app/src/main/kotlin/screen_cast/rtp/RtpPacket.kt create mode 100644 android/app/src/main/kotlin/screen_cast/signaling/SignalingMessage.kt create mode 100644 android/app/src/main/kotlin/screen_cast/signaling/SignalingServer.kt create mode 100644 android/app/src/main/res/layout/activity_receiver.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/test/kotlin/screen_cast/rtp/H264DepacketizerTest.kt create mode 100644 android/app/src/test/kotlin/screen_cast/rtp/JitterBufferTest.kt create mode 100644 android/app/src/test/kotlin/screen_cast/rtp/RtpHeaderTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/settings.gradle.kts diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..1883891 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.kotlin/ +build/ +local.properties +.idea/ +captures/ +.cxx/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..fa8c618 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d27655d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/screen_cast/ReceiverActivity.kt b/android/app/src/main/kotlin/screen_cast/ReceiverActivity.kt new file mode 100644 index 0000000..bfc9f39 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/ReceiverActivity.kt @@ -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" + } + } +} diff --git a/android/app/src/main/kotlin/screen_cast/decode/H264Decoder.kt b/android/app/src/main/kotlin/screen_cast/decode/H264Decoder.kt new file mode 100644 index 0000000..7e347e5 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/decode/H264Decoder.kt @@ -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? { + 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 + } +} diff --git a/android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt b/android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt new file mode 100644 index 0000000..e3f4105 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt @@ -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, + 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 = 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 + } +} diff --git a/android/app/src/main/kotlin/screen_cast/rtp/H264Depacketizer.kt b/android/app/src/main/kotlin/screen_cast/rtp/H264Depacketizer.kt new file mode 100644 index 0000000..416fca9 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/rtp/H264Depacketizer.kt @@ -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() + private var fuActive = false + private val fuNal = ArrayList() + + /** 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() + } +} diff --git a/android/app/src/main/kotlin/screen_cast/rtp/JitterBuffer.kt b/android/app/src/main/kotlin/screen_cast/rtp/JitterBuffer.kt new file mode 100644 index 0000000..d41a18e --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/rtp/JitterBuffer.kt @@ -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() + private var nextExpected: Int? = null + + /** Insert one packet and return the packets now ready for in-order delivery. */ + fun push(packet: RtpPacket): List = synchronized(lock) { + val released = ArrayList() + 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 + } + } +} diff --git a/android/app/src/main/kotlin/screen_cast/rtp/RtpHeader.kt b/android/app/src/main/kotlin/screen_cast/rtp/RtpHeader.kt new file mode 100644 index 0000000..1d95a07 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/rtp/RtpHeader.kt @@ -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), + ) + } + } +} diff --git a/android/app/src/main/kotlin/screen_cast/rtp/RtpPacket.kt b/android/app/src/main/kotlin/screen_cast/rtp/RtpPacket.kt new file mode 100644 index 0000000..b084e45 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/rtp/RtpPacket.kt @@ -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)) + } + } +} diff --git a/android/app/src/main/kotlin/screen_cast/signaling/SignalingMessage.kt b/android/app/src/main/kotlin/screen_cast/signaling/SignalingMessage.kt new file mode 100644 index 0000000..7ffbb17 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/signaling/SignalingMessage.kt @@ -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" + } + } +} diff --git a/android/app/src/main/kotlin/screen_cast/signaling/SignalingServer.kt b/android/app/src/main/kotlin/screen_cast/signaling/SignalingServer.kt new file mode 100644 index 0000000..c67b701 --- /dev/null +++ b/android/app/src/main/kotlin/screen_cast/signaling/SignalingServer.kt @@ -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. + } + } +} diff --git a/android/app/src/main/res/layout/activity_receiver.xml b/android/app/src/main/res/layout/activity_receiver.xml new file mode 100644 index 0000000..cc1aecd --- /dev/null +++ b/android/app/src/main/res/layout/activity_receiver.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..629b16f37612e9bbff609496599b8e9387bc31bf GIT binary patch literal 4494 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..155dcf28eaa585b470aa7f212a6579e41478ab66 GIT binary patch literal 2776 zcmZ`*2T+sC8vO%`AV`yb1%p69iiT=HYAz8-0BKh`f*6X1CJEAsfFKAcij>eXQe$XR z1mx1WbVEnFlz<8mLKQB&+<7x^=FOWoyWgIfv)`F-_sq958)s%}aN)ejc>n+|7#ZqY zFcA7ru(B{#Yo^0k27p}+EldC)7y)RnTO)lv zE04L2jE9&Mv;aPL|L<$cBkRU4>$*j0W2s#%`t1mFj(A1V6Wn$084_D^Vw@a-`?N== z;6(fw?8KXL`M3TC9tqxvbiBNL0T1qMKb-IPmElw+g&M)&Oi$`w7_CmHnU*=oXIGe4 z1<6^)epz4c51R9z+t;FoGEK4_3$yy-c<}3=PEx)C>a^Vlug=a1&Rt)<{bPnpN*y$b zZ<@++C@Rh^TRM9<#k{7Iy4Z7b?&CE%9dX@BsK+yXkJ8ctabA}Iuo;sgp^uV{q-ZO_ z-IaO_)k5f?GoQH=tE$LJEA&+gH|NE1mFOjWQPL7P8aR`LPq1)67O7L_humjpvo4Eu z1(0>Lnamuzg>~u*BIi|Ax6Hz{Ds_SGstN^m^fV0l!~%v$ViVUz5>pHv4IYR{q;^IG zhaBd8${wUpzHiNEDB}4dLzeu;uR9o8!V-F^mGqz#^kN_&^HljP{v~gTC#$2u2u`Ja z;Rus7OC9oqC4Oj z*!I|If=3MRM(`+%gPNSo&JGNY6!pc(gj_rgKRITjDhp${TK2>t2CcWnyFudQXZTSZ zao<*FMWuUju|Z=vwNuB=z7JVc1*8sSb~mI7V5cFu1NTBot84}H0GE7BAC7fq;nx;_ z+pNc&m@kJb)2RR4?KlGwk@|F`UtTr63*>e#>KL~OFC|K*nKqib`xj%z6ZJJV+Lq}~ zy?mO@ZK=!)Fd1>~DN?3({!40;C7?=N-a4codi%a)?^#X+9j}htduFIU57kKAC^87V<>w8+i%~p>ay;qR1t{~zOP(~iA~|(_#O~K?%CmO z5NEkge@!^sUJS|^a&@m>(i>AL19d#M_Z z#5hu$gXpkyLti(KwO_y{xg=lvQkQ8F3Zx|b;0~D>zWbm#X!noc7h(zzcTqM($uBeg zRf0}=;R7P3w8f#3LGoOWQm7>8*Y1GpoTmEG&>A7B5NFAlF1O7arK+?-1$$v^KL8?(x=+_$&9 z1r|83s3X$3mt`HEu^>Ds^_qB&<*y6$+F*mN5(t&wO= z=!nwuj?+I_-CoH6jT&6(S2D3uyXn{XrPf{+n2qIB+}Ws0z~PcHw7jFAF8BIUc@jFC zzIp{KmX#t;0xq3HPd{yJH9y~7%Z4;^Rnd@YNx8~bPU_$;SiYlRK7ETTHlsRFu|z-e zc5ZNdR77x!&wOCiPD_g_h)8ai{M@rA`i?v4cQ2SGolka@j(DdS{>Ia`-?1|C&2*gv zF1vpL9M+vgsdeX)uEXSiU(nkOUYBAV;>{aVgiAW`6X}<7<(`n`^$x33DDg~IWd1#V zi{Xf4m_bfZ%u?o!5!@R07%FZ1^R!R(*OfG*0PN58m?kH-Q9h23^`gPUlZL*7mtKjAveLp_grvaT zM@Pb$DdA`kko9BZofkGGwYL`@_SHtf4&1`HZP?u~gvX}CHBs#ZfFat+h3RR(f!nJ6 zEp2MX^I}N`(f*}IezKyqta$dqk4d%cko`wn?Oc>dy}H(|qxyKNw5y^Xay7QRgs0T{wne7u%}xM4I%(v;Uhw!3(9EX02bTOa3GVMUhCW_&~icRT|zqKxvS#l z`;vV0xO^!y$XMJV3Q>}WZ(4$8^-H^s zAXhaxN4Do6L>I3X8D8-gm*GHC!K|82~6jmDCiV>Wpcn1l3Tzp#fEugF-c+P{(I< z-TxAJ`P}z(3;O>8`7zWnLm=~?3Rq7sv_ICx%lCgZ%Ig0vqC`9QfuRA65T^QMxO3#c E09C>#$^ZZW literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0830ba66aab1d996795ccb6f964dca52d01437d2 GIT binary patch literal 6355 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 X?0*{(K;k!GFaTu*b@>`O^U(hScUKmk literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..1bfeeec1ab463bb9d47bdc847efc2f9011b0311e GIT binary patch literal 10758 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 + + + diff --git a/android/app/src/test/kotlin/screen_cast/rtp/H264DepacketizerTest.kt b/android/app/src/test/kotlin/screen_cast/rtp/H264DepacketizerTest.kt new file mode 100644 index 0000000..3edb5d4 --- /dev/null +++ b/android/app/src/test/kotlin/screen_cast/rtp/H264DepacketizerTest.kt @@ -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) + } +} diff --git a/android/app/src/test/kotlin/screen_cast/rtp/JitterBufferTest.kt b/android/app/src/test/kotlin/screen_cast/rtp/JitterBufferTest.kt new file mode 100644 index 0000000..013f5f0 --- /dev/null +++ b/android/app/src/test/kotlin/screen_cast/rtp/JitterBufferTest.kt @@ -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 }) + } +} diff --git a/android/app/src/test/kotlin/screen_cast/rtp/RtpHeaderTest.kt b/android/app/src/test/kotlin/screen_cast/rtp/RtpHeaderTest.kt new file mode 100644 index 0000000..23e77ee --- /dev/null +++ b/android/app/src/test/kotlin/screen_cast/rtp/RtpHeaderTest.kt @@ -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))) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..f928a84 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.4.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..ed21a4b --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.caching=true +kotlin.code.style=official diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..87f905a --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "screen_cast-android" +include(":app")