diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 6cdcbb3..7237efe 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -304,6 +304,32 @@ Validation: `gradle :app:assembleDebug :app:testDebugUnitTest` green (25/25, full --rerun-tasks rebuild, only two pre-existing warnings); `meson test` 5/5 unchanged (no C++ touched). +### Discovery bug found and fixed on-device (2026-09-10, same day) + +The user reported the desktop sender never discovered the phone. +Root cause (found live on the Fairphone 6): `registerService(info, 0, +listener)` — Android 16's `NsdManager.checkProtocol()` rejects protocol +`0` with `IllegalArgumentException: Unsupported protocol`. The old code +swallowed it into the dead reflection fallback, and `start()` posted the +"Listening…" status AFTER advertiseNsd, overwriting the failure text — so +mDNS never advertised and Phase 8's NSD validation was only ever "no +crash" (8.5 streamed via --peer, masking it). + +Fixes in `ReceiverPipeline.advertiseNsd()`/`start()`: +- pass `NsdManager.PROTOCOL_DNS_SD`; +- advertise AFTER the listening status so a failure stays visible; +- `Log.e` the registration exception; `Log.i` on registered success. + +On-device validation (adb, live): `mDNS registered: +screencast._screencast._tcp` in logcat; `dumpsys servicediscovery` shows +the active Advertiser (key diagnostic: `mClientRequests` empty == no +request ever issued); desktop `avahi-browse` and `screencast --discover` +list the phone at 192.168.178.29:5005; a 25s `--send --target monitor +--peer 192.168.178.29:5005` session decoded (in-band SPS reconfigured +320x240 → 2496x1040) and rendered (screencap mean brightness 0.51). +Note: with both the Pi and the phone on the LAN, plain `--send` refuses +(two receivers found) — target the phone with `--peer`. + Still open (accepted, needs a device or a new test dep): - Untested on device: `setOutputSurface` mid-session (surface switch) and diff --git a/android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt b/android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt index 3020e08..7b83669 100644 --- a/android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt +++ b/android/app/src/main/kotlin/screen_cast/pipeline/ReceiverPipeline.kt @@ -110,11 +110,13 @@ class ReceiverPipeline( running = true readerThread = Thread({ readLoop() }, "rtp-reader").also { it.start() } - advertiseNsd(signalingPort) + // Advertise AFTER the listening status: a registration failure must + // not be overwritten by it (both post to the same overlay). onStatus( "Listening on $localIp (media :$udpPort, signaling :$signalingPort)\n" + "Waiting for a sender… (fall back to: screencast --send --peer $localIp:$signalingPort)", ) + advertiseNsd(signalingPort) } /** @@ -345,6 +347,7 @@ class ReceiverPipeline( val listener = object : NsdManager.RegistrationListener { override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { // Registered: the sender's mDNS browser should see it now. + android.util.Log.i(TAG, "mDNS registered: ${serviceInfo.serviceName}.${SERVICE_TYPE}") } override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) = Unit @@ -356,9 +359,14 @@ class ReceiverPipeline( override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit } try { - nsdManager.registerService(info, 0, listener) + // PROTOCOL_DNS_SD is mandatory on API 36: NsdManager.checkProtocol() + // rejects anything else (the historical 0 threw + // "IllegalArgumentException: Unsupported protocol", which the + // old code swallowed — mDNS never advertised on this phone). + nsdManager.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener) registrationListener = listener } catch (e: Exception) { + android.util.Log.e(TAG, "mDNS registration failed", e) onStatus("mDNS unavailable (${e.message}) — reach this receiver with --peer $localIp:$port") } } diff --git a/docs/PHASES.md b/docs/PHASES.md index bcbc76c..421eb36 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -114,6 +114,13 @@ existing signaling + RTP protocol; no C++ changes. depacketizer — 25 tests green) - [x] 8.3 Signaling + NSD validated on device (offer → answer over the network; mDNS registration via the API-36 RegistrationListener) + — **correction 2026-09-10**: registration had in fact never + succeeded (registerService passed protocol `0`, which API 36 + rejects with "Unsupported protocol"; the swallowed failure was + hidden behind the "Listening…" status). Fixed with + `PROTOCOL_DNS_SD` + real on-device validation: desktop + `--discover` lists the phone and a live `--send` session + decodes and renders. - [x] 8.4 MediaCodec decode + Surface render validated on device (in-band SPS sizing, letterbox fit) - [x] 8.5 End-to-end on a Fairphone 6 (Android 16): streaming, letterboxed diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index ce971d2..14cb747 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -240,6 +240,16 @@ Platform quirks found while validating (Android 16 / API 36): - `android.permission.INTERNET` is required — NsdService rejects registration without it. +- `NsdManager.registerService` on Android 16 validates the protocol + argument (`NsdManager.checkProtocol`): the historical `0` throws + `IllegalArgumentException: Unsupported protocol` — pass + `NsdManager.PROTOCOL_DNS_SD`. This was silent for a long time: the app + swallowed the exception and the failure status was overwritten by the + "Listening…" line, so mDNS never advertised even though the UI looked + fine (discovery only worked via `--peer`). Diagnosed via + `adb shell dumpsys servicediscovery` (the client's `mClientRequests` + stays empty when no request was ever issued) plus logging the + exception. - `DatagramSocket.localPort` gives the bound port; `.port` is -1 for unconnected datagram sockets, and `.localAddress` is an `InetAddress` (Inet6Address on Android), not an `InetSocketAddress`.