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