1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:39:15 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-16 18:50:36 +02:00
parent a48fbe2ee7
commit 0a6a4269f4
68 changed files with 3816 additions and 305 deletions

View File

@@ -29,10 +29,17 @@ android {
buildFeatures { buildFeatures {
aidl true aidl true
} }
testOptions {
unitTests.includeAndroidResources = true
}
} }
dependencies { dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
testImplementation 'junit:junit:4.13.2'
} }

View File

@@ -11,6 +11,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application <application

View File

@@ -3,8 +3,12 @@ package com.foxx.androidcast;
/** Shared constants for the LAN cast POC. */ /** Shared constants for the LAN cast POC. */
public final class CastConfig { public final class CastConfig {
public static final int DISCOVERY_PORT = 41234; public static final int DISCOVERY_PORT = 41234;
/** One UDP browse pass duration (progress bar on sender). */ /** One UDP browse pass duration (sender shows countdown on Refresh). */
public static final long DISCOVERY_SCAN_PASS_MS = 4_000; public static final long DISCOVERY_SCAN_PASS_MS = 30_000;
/** Idle gap between automatic browse passes. */
public static final long DISCOVERY_PAUSE_MS = 30_000;
/** How often the sender broadcasts a discover probe during a scan pass. */
public static final long DISCOVERY_PROBE_INTERVAL_MS = 1_000;
public static final int CAST_PORT = 41235; public static final int CAST_PORT = 41235;
public static final String DISCOVERY_MAGIC = "ACAST1"; public static final String DISCOVERY_MAGIC = "ACAST1";
@@ -16,6 +20,19 @@ public final class CastConfig {
public static final String TRANSPORT_TCP = "tcp"; public static final String TRANSPORT_TCP = "tcp";
public static final String TRANSPORT_UDP = "udp"; public static final String TRANSPORT_UDP = "udp";
public static final String TRANSPORT_QUIC = "quic";
public static final String TRANSPORT_WEBRTC = "webrtc";
/** Default cast transport (UDP); TCP/QUIC remain selectable in settings. */
public static final String DEFAULT_TRANSPORT = TRANSPORT_UDP;
/** Dedicated port for experimental QUIC transport (UDP/TCP framed). */
public static final int QUIC_PORT = 41236;
public static int portForTransport(String transport) {
if (TRANSPORT_QUIC.equals(transport)) {
return QUIC_PORT;
}
return CAST_PORT;
}
/** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */ /** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */
public static final int VIDEO_I_FRAME_INTERVAL = 1; public static final int VIDEO_I_FRAME_INTERVAL = 1;
@@ -28,7 +45,7 @@ public final class CastConfig {
public static final int AUDIO_SAMPLE_RATE = 44100; public static final int AUDIO_SAMPLE_RATE = 44100;
public static final int AUDIO_CHANNEL_COUNT = 2; public static final int AUDIO_CHANNEL_COUNT = 2;
public static final int AUDIO_BITRATE = 128_000; public static final int AUDIO_BITRATE = 96_000;
/** Max UDP payload chunk (fits typical WiFi MTU with header). */ /** Max UDP payload chunk (fits typical WiFi MTU with header). */
public static final int UDP_CHUNK_SIZE = 1200; public static final int UDP_CHUNK_SIZE = 1200;
@@ -36,6 +53,9 @@ public final class CastConfig {
/** Show diagnostics overlay on receiver playback (debug). */ /** Show diagnostics overlay on receiver playback (debug). */
public static final boolean SHOW_CAST_DIAGNOSTICS = true; public static final boolean SHOW_CAST_DIAGNOSTICS = true;
/** Max retained session stat JSON files (room for one new file on next session). */
public static final int SESSION_STATS_MAX_FILES = 20;
/** No video packets for this long → treat stream as stopped (robot overlay). */ /** No video packets for this long → treat stream as stopped (robot overlay). */
/** No inbound video this long → robot overlay (keep above ~2× slowest expected frame interval). */ /** No inbound video this long → robot overlay (keep above ~2× slowest expected frame interval). */
public static final long STREAM_IDLE_TIMEOUT_MS = 8_000; public static final long STREAM_IDLE_TIMEOUT_MS = 8_000;

View File

@@ -70,4 +70,9 @@ public final class CastResolution {
private static int even(int v) { private static int even(int v) {
return Math.max(2, v & ~1); return Math.max(2, v & ~1);
} }
/** Fixed 16:9 landscape chart size for calibration test mode (receiver letterboxing). */
public static Size calibrationTestSize() {
return new Size(960, 540, 240);
}
} }

View File

@@ -46,7 +46,14 @@ public class CastSettings implements Serializable {
/** Whole display (default). */ /** Whole display (default). */
FULL_SCREEN, FULL_SCREEN,
/** Android 14+: system picker can target a single app (better app audio capture). */ /** Android 14+: system picker can target a single app (better app audio capture). */
USER_CHOICE USER_CHOICE,
/** Generated TV calibration pattern (no MediaProjection). */
CALIBRATION_TEST,
/**
* Future: Camera2 preview as video source (not MediaProjection app picker).
* {@link PlatformCastSupport#isCameraCaptureModeSupported()}
*/
CAMERA
} }
public enum Resolution { public enum Resolution {
@@ -69,7 +76,7 @@ public class CastSettings implements Serializable {
} }
} }
private String transport = CastConfig.TRANSPORT_TCP; private String transport = CastConfig.DEFAULT_TRANSPORT;
private Quality quality = Quality.MEDIUM; private Quality quality = Quality.MEDIUM;
private Resolution resolution = Resolution.HD_720P; private Resolution resolution = Resolution.HD_720P;
private boolean audioEnabled; private boolean audioEnabled;

View File

@@ -8,12 +8,15 @@ import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.receiver.ReceiverActivity; import com.foxx.androidcast.receiver.ReceiverActivity;
import com.foxx.androidcast.sender.SenderActivity; import com.foxx.androidcast.sender.SenderActivity;
import com.foxx.androidcast.stats.SessionStatsStore;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
SessionStatsStore.pruneOnAppStart(this);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
com.foxx.androidcast.network.CastTransportFactory.init(this);
PermissionHelper.request(this); PermissionHelper.request(this);
Button sendBtn = findViewById(R.id.btn_send); Button sendBtn = findViewById(R.id.btn_send);

View File

@@ -25,6 +25,13 @@ public final class PlatformCastSupport {
: CastSettings.CaptureMode.FULL_SCREEN; : CastSettings.CaptureMode.FULL_SCREEN;
} }
/**
* Camera2 as a video source is separate from MediaProjection (app picker cannot select camera only).
*/
public static boolean isCameraCaptureModeSupported() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
}
/** Applies default audio flag: on for Android 10+, off otherwise. */ /** Applies default audio flag: on for Android 10+, off otherwise. */
public static void applyDefaultAudioSetting(CastSettings settings) { public static void applyDefaultAudioSetting(CastSettings settings) {
settings.setAudioEnabled(isAudioCaptureSupported()); settings.setAudioEnabled(isAudioCaptureSupported());

View File

@@ -50,10 +50,36 @@ public final class SettingsUi {
Spinner resolution = activity.findViewById(R.id.spinner_resolution); Spinner resolution = activity.findViewById(R.id.spinner_resolution);
bindSpinner(activity, transport, new String[]{ bindSpinner(activity, transport, new String[]{
activity.getString(R.string.transport_udp),
activity.getString(R.string.transport_tcp), activity.getString(R.string.transport_tcp),
activity.getString(R.string.transport_udp) activity.getString(R.string.transport_quic),
}, position -> settings.setTransport( activity.getString(R.string.transport_webrtc)
position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP)); }, position -> {
switch (position) {
case 1:
settings.setTransport(CastConfig.TRANSPORT_TCP);
break;
case 2:
settings.setTransport(CastConfig.TRANSPORT_QUIC);
break;
case 3:
settings.setTransport(CastConfig.TRANSPORT_WEBRTC);
break;
case 0:
default:
settings.setTransport(CastConfig.TRANSPORT_UDP);
break;
}
});
int transportIndex = 0;
if (CastConfig.TRANSPORT_TCP.equals(settings.getTransport())) {
transportIndex = 1;
} else if (CastConfig.TRANSPORT_QUIC.equals(settings.getTransport())) {
transportIndex = 2;
} else if (CastConfig.TRANSPORT_WEBRTC.equals(settings.getTransport())) {
transportIndex = 3;
}
transport.setSelection(transportIndex);
List<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices(); List<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
List<String> codecLabels = new ArrayList<>(); List<String> codecLabels = new ArrayList<>();
@@ -106,29 +132,42 @@ public final class SettingsUi {
if (captureMode == null) { if (captureMode == null) {
return; return;
} }
java.util.List<String> modes = new java.util.ArrayList<>();
java.util.List<CastSettings.CaptureMode> modeValues = new java.util.ArrayList<>();
modes.add(activity.getString(R.string.capture_full_screen));
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
if (PlatformCastSupport.isCaptureUserChoiceSupported()) { if (PlatformCastSupport.isCaptureUserChoiceSupported()) {
bindSpinner(activity, captureMode, new String[]{ modes.add(activity.getString(R.string.capture_user_choice));
activity.getString(R.string.capture_full_screen), modeValues.add(CastSettings.CaptureMode.USER_CHOICE);
activity.getString(R.string.capture_user_choice)
}, position -> settings.setCaptureMode(
position == 1 ? CastSettings.CaptureMode.USER_CHOICE : CastSettings.CaptureMode.FULL_SCREEN));
captureMode.setSelection(settings.getCaptureMode() == CastSettings.CaptureMode.USER_CHOICE ? 1 : 0);
captureMode.setEnabled(true);
if (label != null) {
label.setText(R.string.label_capture_mode);
}
} else { } else {
String unavailable = activity.getString(R.string.capture_user_choice_unavailable, modes.add(activity.getString(R.string.capture_user_choice_unavailable, Build.VERSION.SDK_INT));
Build.VERSION.SDK_INT); modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
bindSpinner(activity, captureMode, new String[]{ }
activity.getString(R.string.capture_full_screen), modes.add(activity.getString(R.string.capture_calibration_test));
unavailable modeValues.add(CastSettings.CaptureMode.CALIBRATION_TEST);
}, position -> settings.setCaptureMode(CastSettings.CaptureMode.FULL_SCREEN)); if (PlatformCastSupport.isCameraCaptureModeSupported()) {
captureMode.setSelection(0); modes.add(activity.getString(R.string.capture_camera));
captureMode.setEnabled(false); modeValues.add(CastSettings.CaptureMode.CAMERA);
if (label != null) { } else {
label.setText(activity.getString(R.string.label_capture_mode_sender_only)); modes.add(activity.getString(R.string.capture_camera_unavailable));
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
}
bindSpinner(activity, captureMode, modes.toArray(new String[0]), position -> {
if (position >= 0 && position < modeValues.size()) {
settings.setCaptureMode(modeValues.get(position));
} }
});
int sel = 0;
for (int i = 0; i < modeValues.size(); i++) {
if (modeValues.get(i) == settings.getCaptureMode()) {
sel = i;
break;
}
}
captureMode.setSelection(sel);
captureMode.setEnabled(true);
if (label != null) {
label.setText(R.string.label_capture_mode);
} }
} }

View File

@@ -135,6 +135,10 @@ public final class CastDiagnosticsFormatter {
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0)); html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
html.append("<br/>"); html.append("<br/>");
appendUdpLossSection(html, peer, lastLocal);
appendProtectionSection(html, peer, lastLocal);
html.append("Ctrl: ").append(escape(ctrl)); html.append("Ctrl: ").append(escape(ctrl));
html.append("<br/>"); html.append("<br/>");
html.append("Mode: ").append(escape(bitrateMode)); html.append("Mode: ").append(escape(bitrateMode));
@@ -261,6 +265,110 @@ public final class CastDiagnosticsFormatter {
return html.toString(); return html.toString();
} }
private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer,
NetworkStatsSnapshot local) {
long rx = pick(peer, local, true, false);
long tx = pick(peer, local, true, true);
long lost = val(peer, local, p -> p.udpReassemblyLost, l -> l.udpReassemblyLost);
long invalid = val(peer, local, p -> p.udpDatagramsInvalid, l -> l.udpDatagramsInvalid);
long dup = val(peer, local, p -> p.udpFragmentsDuplicate, l -> l.udpFragmentsDuplicate);
long wireDrop = val(peer, local, p -> p.udpWireDropped, l -> l.udpWireDropped);
long skipKey = val(peer, local, p -> p.recvVideoSkippedKey, l -> l.recvVideoSkippedKey);
long vDecErr = val(peer, local, p -> p.recvVideoDecodeErrors, l -> l.recvVideoDecodeErrors);
long aDecErr = val(peer, local, p -> p.recvAudioDecodeErrors, l -> l.recvAudioDecodeErrors);
long aDrop = val(peer, local, p -> p.recvAudioQueueDrops, l -> l.recvAudioQueueDrops);
long dropAudio = val(peer, local, p -> (long) p.droppedAudioFrames, l -> (long) l.droppedAudioFrames);
if (rx == 0 && tx == 0 && lost == 0 && invalid == 0) {
return;
}
html.append("<font color='").append(COLOR_DIM).append("'>UDP:</font> ");
html.append("rx ").append(rx).append(" / tx ").append(tx).append(" dg");
if (lost > 0) {
html.append(" ").append(colored("reasm-lost " + lost, true));
}
if (invalid > 0) {
html.append(" ").append(colored("bad " + invalid, true));
}
if (dup > 0) {
html.append(" dup ").append(dup);
}
if (wireDrop > 0) {
html.append(" ").append(colored("sess-drop " + wireDrop, true));
}
html.append("<br/>");
if (skipKey > 0 || vDecErr > 0 || aDecErr > 0 || aDrop > 0 || dropAudio > 0) {
html.append("App drops: ");
if (skipKey > 0) {
html.append(colored("vid-wait-key " + skipKey, skipKey > 50));
}
if (vDecErr > 0) {
html.append(' ').append(colored("vid-dec " + vDecErr, true));
}
if (aDecErr > 0) {
html.append(' ').append(colored("aud-dec " + aDecErr, true));
}
if (aDrop > 0) {
html.append(' ').append(colored("aud-q " + aDrop, true));
}
if (dropAudio > 0) {
html.append(' ').append(colored("snd-aud " + dropAudio, true));
}
html.append("<br/>");
}
}
private static void appendProtectionSection(StringBuilder html, NetworkStatsSnapshot peer,
NetworkStatsSnapshot local) {
String mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE");
html.append("<font color='").append(COLOR_DIM).append("'>Protect:</font> ");
html.append(escape(mode));
html.append(" · FEC <font color='").append(COLOR_DIM).append("'>stub (off)</font>");
html.append(" · NACK <font color='").append(COLOR_DIM).append("'>stub (off)</font>");
long nack = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests);
if (nack > 0) {
html.append(" nack=").append(nack);
}
html.append("<br/>");
}
private static long pick(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
boolean preferLocal, boolean tx) {
if (preferLocal && local != null) {
return tx ? local.udpDatagramsTx : local.udpDatagramsRx;
}
if (peer != null) {
return tx ? peer.udpDatagramsTx : peer.udpDatagramsRx;
}
return local != null ? (tx ? local.udpDatagramsTx : local.udpDatagramsRx) : 0;
}
private static long val(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
java.util.function.Function<NetworkStatsSnapshot, Long> peerFn,
java.util.function.Function<NetworkStatsSnapshot, Long> localFn) {
long v = 0;
if (local != null) {
v = Math.max(v, localFn.apply(local));
}
if (peer != null) {
v = Math.max(v, peerFn.apply(peer));
}
return v;
}
private static String str(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
java.util.function.Function<NetworkStatsSnapshot, String> peerFn,
java.util.function.Function<NetworkStatsSnapshot, String> localFn,
String def) {
if (local != null && localFn.apply(local) != null && !localFn.apply(local).isEmpty()) {
return localFn.apply(local);
}
if (peer != null && peerFn.apply(peer) != null && !peerFn.apply(peer).isEmpty()) {
return peerFn.apply(peer);
}
return def;
}
private static List<String> buildHints(float inFps, float renderFps, int bwInKbps, private static List<String> buildHints(float inFps, float renderFps, int bwInKbps,
int rttMs, long videoPackets) { int rttMs, long videoPackets) {
List<String> hints = new ArrayList<>(); List<String> hints = new ArrayList<>();

View File

@@ -117,6 +117,10 @@ public final class CastDiagnosticsLabels {
switch (mode) { switch (mode) {
case USER_CHOICE: case USER_CHOICE:
return "Choose app or screen (Android 14+)"; return "Choose app or screen (Android 14+)";
case CALIBRATION_TEST:
return "Calibration test pattern";
case CAMERA:
return "Camera (Camera2, planned)";
case FULL_SCREEN: case FULL_SCREEN:
default: default:
return "Entire screen"; return "Entire screen";

View File

@@ -92,6 +92,10 @@ public class DiscoveryManager {
this.listener = listener; this.listener = listener;
} }
public boolean isBrowsing() {
return running.get();
}
public void startBrowsing() { public void startBrowsing() {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return; return;
@@ -148,7 +152,13 @@ public class DiscoveryManager {
sock.setSoTimeout(300); sock.setSoTimeout(300);
byte[] buf = new byte[1024]; byte[] buf = new byte[1024];
long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS; long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS;
long nextProbeMs = System.currentTimeMillis();
while (running.get() && System.currentTimeMillis() < deadline) { while (running.get() && System.currentTimeMillis() < deadline) {
long now = System.currentTimeMillis();
if (now >= nextProbeMs) {
sendDiscoveryProbe(sock);
nextProbeMs = now + CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
}
DatagramPacket packet = new DatagramPacket(buf, buf.length); DatagramPacket packet = new DatagramPacket(buf, buf.length);
try { try {
sock.receive(packet); sock.receive(packet);
@@ -200,26 +210,25 @@ public class DiscoveryManager {
try (DatagramSocket sock = new DatagramSocket()) { try (DatagramSocket sock = new DatagramSocket()) {
socket = sock; socket = sock;
sock.setBroadcast(true); sock.setBroadcast(true);
sock.setSoTimeout(400);
while (running.get()) { while (running.get()) {
JSONObject json = new JSONObject(); sendAnnounce(sock, context, deviceName, port, transport, listening,
json.put("magic", CastConfig.DISCOVERY_MAGIC); placeholderId, videoMask, audioMask);
json.put("name", deviceName); long listenUntil = System.currentTimeMillis() + 2_000;
json.put("port", port); while (running.get() && System.currentTimeMillis() < listenUntil) {
json.put("transport", transport); try {
json.put("listening", listening); byte[] buf = new byte[1024];
json.put("device_uuid", DeviceInfo.getDeviceUuid(context)); DatagramPacket packet = new DatagramPacket(buf, buf.length);
byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); sock.receive(packet);
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask); if (isDiscoverProbe(packet)) {
byte[] wire; sendAnnounce(sock, context, deviceName, port, transport, listening,
try { placeholderId, videoMask, audioMask);
wire = CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes); sendAnnounceUnicast(sock, context, deviceName, port, transport, listening,
} catch (Exception e) { placeholderId, videoMask, audioMask, packet.getAddress());
wire = jsonBytes; }
} catch (java.net.SocketTimeoutException ignored) {
}
} }
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
sock.send(packet);
Thread.sleep(2000);
} }
} catch (Exception e) { } catch (Exception e) {
if (running.get()) { if (running.get()) {
@@ -232,6 +241,73 @@ public class DiscoveryManager {
} }
} }
private static void sendDiscoveryProbe(DatagramSocket sock) {
try {
JSONObject json = new JSONObject();
json.put("magic", CastConfig.DISCOVERY_MAGIC);
json.put("discover", true);
byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8);
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, CastConfig.DISCOVERY_PORT);
sock.send(packet);
} catch (Exception e) {
Log.w(TAG, "Discovery probe send failed: " + e.getMessage());
}
}
private static boolean isDiscoverProbe(DatagramPacket packet) {
try {
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(),
StandardCharsets.UTF_8);
JSONObject json = new JSONObject(text);
return CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))
&& json.optBoolean("discover", false);
} catch (Exception e) {
return false;
}
}
private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port,
String transport, boolean listening, int placeholderId, int videoMask, int audioMask)
throws Exception {
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
placeholderId, videoMask, audioMask);
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
sock.send(packet);
}
private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port,
String transport, boolean listening, int placeholderId, int videoMask, int audioMask,
InetAddress target) {
try {
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
placeholderId, videoMask, audioMask);
DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT);
sock.send(packet);
} catch (Exception e) {
Log.w(TAG, "Unicast announce failed: " + e.getMessage());
}
}
private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport,
boolean listening, int placeholderId, int videoMask, int audioMask) throws Exception {
JSONObject json = new JSONObject();
json.put("magic", CastConfig.DISCOVERY_MAGIC);
json.put("name", deviceName);
json.put("port", port);
json.put("transport", transport);
json.put("listening", listening);
json.put("device_uuid", DeviceInfo.getDeviceUuid(context));
byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
try {
return CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes);
} catch (Exception e) {
return jsonBytes;
}
}
private void handlePacket(DatagramPacket packet) { private void handlePacket(DatagramPacket packet) {
try { try {
byte[] raw = packet.getData(); byte[] raw = packet.getData();
@@ -274,7 +350,7 @@ public class DiscoveryManager {
} }
String name = json.optString("name", "Unknown"); String name = json.optString("name", "Unknown");
int port = json.optInt("port", CastConfig.CAST_PORT); int port = json.optInt("port", CastConfig.CAST_PORT);
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP); String transport = json.optString("transport", CastConfig.DEFAULT_TRANSPORT);
if (!json.optBoolean("listening", true)) { if (!json.optBoolean("listening", true)) {
return; return;
} }

View File

@@ -72,4 +72,8 @@ public final class CodecNegotiator {
public static boolean isH264(String mime) { public static boolean isH264(String mime) {
return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime); return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime);
} }
public static boolean isHevc(String mime) {
return MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime);
}
} }

View File

@@ -0,0 +1,32 @@
package com.foxx.androidcast.media;
import android.media.MediaFormat;
/** Inspects encoded bitstream headers for diagnostics. */
public final class EncodedStreamProbe {
private EncodedStreamProbe() {}
public static String describeKeyframe(String mime, byte[] annexB) {
if (annexB == null || annexB.length < 5) {
return "empty";
}
int offset = 0;
if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1) {
offset = 4;
} else if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 1) {
offset = 3;
}
if (offset >= annexB.length) {
return "no start code";
}
if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) {
int nalType = (annexB[offset] >> 1) & 0x3F;
return "HEVC NAL type " + nalType + " (" + annexB.length + " B)";
}
if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) {
int nalType = annexB[offset] & 0x1F;
return "AVC NAL type " + nalType + " (" + annexB.length + " B)";
}
return "encoded " + annexB.length + " B";
}
}

View File

@@ -2,7 +2,10 @@ package com.foxx.androidcast.media;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
/** Converts H.264 AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. */ /**
* Converts H.264 / HEVC AVCC (length-prefixed) NAL units to Annex-B start codes for decoders.
* MediaCodec encoders on Android typically emit AVCC; many decoders expect Annex-B in input buffers.
*/
public final class H264Bitstream { public final class H264Bitstream {
private static final byte[] START_CODE = {0, 0, 0, 1}; private static final byte[] START_CODE = {0, 0, 0, 1};

View File

@@ -0,0 +1,52 @@
package com.foxx.androidcast.media;
import android.graphics.Bitmap;
/** ARGB bitmap to NV21 for YUV420 encoder input. */
public final class RgbToNv21 {
private RgbToNv21() {}
public static byte[] fromBitmap(Bitmap bitmap) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int[] argb = new int[width * height];
bitmap.getPixels(argb, 0, width, 0, 0, width, height);
return fromArgb(argb, width, height);
}
public static byte[] fromArgb(int[] argb, int width, int height) {
int frameSize = width * height;
int ySize = frameSize;
int uvSize = frameSize / 2;
byte[] nv21 = new byte[ySize + uvSize];
int yIndex = 0;
int uvIndex = ySize;
for (int j = 0; j < height; j++) {
for (int i = 0; i < width; i++) {
int color = argb[j * width + i];
int r = (color >> 16) & 0xff;
int g = (color >> 8) & 0xff;
int b = color & 0xff;
int y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
nv21[yIndex++] = (byte) clamp(y);
if ((j & 1) == 0 && (i & 1) == 0) {
nv21[uvIndex++] = (byte) clamp(v);
nv21[uvIndex++] = (byte) clamp(u);
}
}
}
return nv21;
}
private static int clamp(int v) {
if (v < 0) {
return 0;
}
if (v > 255) {
return 255;
}
return v;
}
}

View File

@@ -0,0 +1,115 @@
package com.foxx.androidcast.media;
import android.graphics.Bitmap;
/**
* ARGB to YUV420 planar (I420) or semi-planar (NV12) for MediaCodec buffer input.
*/
public final class RgbToYuv420 {
public enum Layout {
I420,
NV12
}
private RgbToYuv420() {}
public static byte[] fromBitmap(Bitmap bitmap, Layout layout) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int[] argb = new int[width * height];
bitmap.getPixels(argb, 0, width, 0, 0, width, height);
return fromArgb(argb, width, height, layout);
}
public static byte[] fromArgb(int[] argb, int width, int height, Layout layout) {
if (layout == Layout.NV12) {
return toNv12(argb, width, height);
}
return toI420(argb, width, height);
}
public static int bufferSize(int width, int height) {
return width * height + (width * height) / 2;
}
private static byte[] toI420(int[] argb, int width, int height) {
int frameSize = width * height;
int chromaW = (width + 1) / 2;
int chromaH = (height + 1) / 2;
int chromaLen = chromaW * chromaH;
byte[] out = new byte[frameSize + chromaLen * 2];
int uOffset = frameSize;
int vOffset = frameSize + chromaLen;
for (int j = 0; j < height; j++) {
for (int i = 0; i < width; i++) {
int color = argb[j * width + i];
int r = (color >> 16) & 0xff;
int g = (color >> 8) & 0xff;
int b = color & 0xff;
int y = yFromRgb(r, g, b);
out[j * width + i] = (byte) y;
if ((j & 1) == 0 && (i & 1) == 0) {
int u = uFromRgb(r, g, b);
int v = vFromRgb(r, g, b);
int cx = i >> 1;
int cy = j >> 1;
int cIndex = cy * chromaW + cx;
out[uOffset + cIndex] = (byte) u;
out[vOffset + cIndex] = (byte) v;
}
}
}
return out;
}
private static byte[] toNv12(int[] argb, int width, int height) {
int frameSize = width * height;
int chromaW = (width + 1) / 2;
int chromaH = (height + 1) / 2;
byte[] out = new byte[frameSize + chromaW * chromaH * 2];
int uvOffset = frameSize;
for (int j = 0; j < height; j++) {
for (int i = 0; i < width; i++) {
int color = argb[j * width + i];
int r = (color >> 16) & 0xff;
int g = (color >> 8) & 0xff;
int b = color & 0xff;
out[j * width + i] = (byte) yFromRgb(r, g, b);
if ((j & 1) == 0 && (i & 1) == 0) {
int u = uFromRgb(r, g, b);
int v = vFromRgb(r, g, b);
int cx = i >> 1;
int cy = j >> 1;
int uvIndex = uvOffset + (cy * chromaW + cx) * 2;
out[uvIndex] = (byte) u;
out[uvIndex + 1] = (byte) v;
}
}
}
return out;
}
private static int yFromRgb(int r, int g, int b) {
return clamp(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16);
}
private static int uFromRgb(int r, int g, int b) {
return clamp(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128);
}
private static int vFromRgb(int r, int g, int b) {
return clamp(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128);
}
private static int clamp(int v) {
if (v < 0) {
return 0;
}
if (v > 255) {
return 255;
}
return v;
}
}

View File

@@ -24,14 +24,22 @@ public final class CastPacketFramer {
return out; return out;
} }
public static CastProtocol.Message unframe(byte[] wire) throws IOException { public static CastProtoHeader readHeader(byte[] wire) throws IOException {
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) { if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1) {
throw new IOException("Frame too short"); throw new IOException("Frame too short");
} }
CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length); CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length);
if (!hdr.verifyChecksum()) { if (!hdr.verifyChecksum()) {
throw new IOException("Header checksum mismatch"); throw new IOException("Header checksum mismatch");
} }
return hdr;
}
public static CastProtocol.Message unframe(byte[] wire) throws IOException {
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) {
throw new IOException("Frame too short");
}
CastProtoHeader hdr = readHeader(wire);
int innerOff = hdr.headerSize; int innerOff = hdr.headerSize;
if (wire.length < innerOff + 5) { if (wire.length < innerOff + 5) {
throw new IOException("Missing message body"); throw new IOException("Missing message body");

View File

@@ -255,7 +255,7 @@ public final class CastProtocol {
return local.getTransport().equals(remote.getTransport()); return local.getTransport().equals(remote.getTransport());
} }
private static final byte NETWORK_STATS_VERSION = 3; private static final byte NETWORK_STATS_VERSION = 4;
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException { public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
@@ -283,6 +283,28 @@ public final class CastProtocol {
dos.writeFloat(s.audioCapturePeakDb); dos.writeFloat(s.audioCapturePeakDb);
dos.writeFloat(s.audioCaptureRmsDb); dos.writeFloat(s.audioCaptureRmsDb);
} }
if (NETWORK_STATS_VERSION >= 4) {
dos.writeLong(s.udpDatagramsRx);
dos.writeLong(s.udpDatagramsTx);
dos.writeLong(s.udpDatagramsInvalid);
dos.writeLong(s.udpFragmentsRx);
dos.writeLong(s.udpFragmentsDuplicate);
dos.writeLong(s.udpReassemblyOk);
dos.writeLong(s.udpReassemblyLost);
dos.writeLong(s.udpWireDropped);
dos.writeLong(s.udpMalformed);
dos.writeInt(s.droppedAudioFrames);
dos.writeLong(s.recvVideoPackets);
dos.writeLong(s.recvAudioPackets);
dos.writeLong(s.recvVideoSkippedKey);
dos.writeLong(s.recvVideoDecodeErrors);
dos.writeLong(s.recvAudioDecodeErrors);
dos.writeLong(s.recvAudioQueueDrops);
dos.writeLong(s.recvDamagedVideoFrames);
dos.writeUTF(s.protectionMode != null ? s.protectionMode : "NONE");
dos.writeLong(s.fecStubPackets);
dos.writeLong(s.nackStubRequests);
}
dos.flush(); dos.flush();
return bos.toByteArray(); return bos.toByteArray();
} }
@@ -315,6 +337,28 @@ public final class CastProtocol {
s.audioCapturePeakDb = dis.readFloat(); s.audioCapturePeakDb = dis.readFloat();
s.audioCaptureRmsDb = dis.readFloat(); s.audioCaptureRmsDb = dis.readFloat();
} }
if (ver >= 4 && dis.available() > 0) {
s.udpDatagramsRx = dis.readLong();
s.udpDatagramsTx = dis.readLong();
s.udpDatagramsInvalid = dis.readLong();
s.udpFragmentsRx = dis.readLong();
s.udpFragmentsDuplicate = dis.readLong();
s.udpReassemblyOk = dis.readLong();
s.udpReassemblyLost = dis.readLong();
s.udpWireDropped = dis.readLong();
s.udpMalformed = dis.readLong();
s.droppedAudioFrames = dis.readInt();
s.recvVideoPackets = dis.readLong();
s.recvAudioPackets = dis.readLong();
s.recvVideoSkippedKey = dis.readLong();
s.recvVideoDecodeErrors = dis.readLong();
s.recvAudioDecodeErrors = dis.readLong();
s.recvAudioQueueDrops = dis.readLong();
s.recvDamagedVideoFrames = dis.readLong();
s.protectionMode = dis.readUTF();
s.fecStubPackets = dis.readLong();
s.nackStubRequests = dis.readLong();
}
} }
} }
return s; return s;
@@ -324,7 +368,7 @@ public final class CastProtocol {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos); DataOutputStream dos = new DataOutputStream(bos);
dos.writeInt(advice.suggestedBitrateKbps); dos.writeInt(advice.suggestedBitrateKbps);
dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.TRANSPORT_TCP); dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.DEFAULT_TRANSPORT);
dos.writeByte(advice.switchTransport ? 1 : 0); dos.writeByte(advice.switchTransport ? 1 : 0);
dos.writeInt(advice.congestionLevel); dos.writeInt(advice.congestionLevel);
dos.flush(); dos.flush();

View File

@@ -0,0 +1,37 @@
package com.foxx.androidcast.network;
/**
* Binds to the first non-zero session id on a connection and drops stale packets from other casts.
*/
public final class CastSessionGate {
private static final int UNBOUND = Integer.MIN_VALUE;
private int boundSessionId = UNBOUND;
public boolean accept(CastProtoHeader header) {
if (header == null) {
return true;
}
int sid = header.sessionId;
if (sid == 0) {
return true;
}
if (boundSessionId == UNBOUND) {
boundSessionId = sid;
return true;
}
return boundSessionId == sid;
}
public void reset() {
boundSessionId = UNBOUND;
}
public boolean isBound() {
return boundSessionId != UNBOUND;
}
public int getBoundSessionId() {
return boundSessionId == UNBOUND ? 0 : boundSessionId;
}
}

View File

@@ -1,5 +1,7 @@
package com.foxx.androidcast.network; package com.foxx.androidcast.network;
import com.foxx.androidcast.network.transport.TransportLossStats;
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
@@ -7,6 +9,9 @@ import java.io.IOException;
public interface CastTransport extends Closeable { public interface CastTransport extends Closeable {
/** Optional proto header context; ignored when null. */ /** Optional proto header context; ignored when null. */
default void setFramingContext(CastFramingContext context) {} default void setFramingContext(CastFramingContext context) {}
/** Drops inbound wire packets whose session id does not match the active cast. */
default void setInboundSessionGate(CastSessionGate gate) {}
/** Server: bind and wait for first peer datagram / connection. */ /** Server: bind and wait for first peer datagram / connection. */
void listen(int port) throws IOException; void listen(int port) throws IOException;
@@ -28,6 +33,11 @@ public interface CastTransport extends Closeable {
String getModeLabel(); String getModeLabel();
/** Loss/damage counters when supported (UDP); empty stats otherwise. */
default TransportLossStats getLossStats() {
return new TransportLossStats();
}
/** Server: drop the active client socket but keep the listen socket bound. */ /** Server: drop the active client socket but keep the listen socket bound. */
default void releaseConnection() { default void releaseConnection() {
close(); close();

View File

@@ -1,15 +1,35 @@
package com.foxx.androidcast.network; package com.foxx.androidcast.network;
import android.content.Context;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory;
import java.io.IOException; import java.io.IOException;
public final class CastTransportFactory { public final class CastTransportFactory {
private static Context appContext;
private CastTransportFactory() {} private CastTransportFactory() {}
public static void init(Context context) {
appContext = context.getApplicationContext();
}
public static CastTransport create(CastSettings settings) throws IOException { public static CastTransport create(CastSettings settings) throws IOException {
if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) { String mode = settings.getTransport();
if (CastConfig.TRANSPORT_QUIC.equals(mode)) {
if (appContext == null) {
throw new IOException("CastTransportFactory.init(context) required for QUIC");
}
return new QuicCronetCastTransport(appContext);
}
if (WebRtcTransportFactory.isTransportId(mode)) {
return WebRtcTransportFactory.create();
}
if (CastConfig.TRANSPORT_UDP.equals(mode)) {
return new UdpCastTransport(); return new UdpCastTransport();
} }
return new TcpCastTransport(); return new TcpCastTransport();

View File

@@ -5,6 +5,7 @@ import java.io.BufferedOutputStream;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
@@ -18,6 +19,7 @@ public class TcpCastTransport implements CastTransport {
private final Object sendLock = new Object(); private final Object sendLock = new Object();
private CastFramingContext framingContext; private CastFramingContext framingContext;
private CastSessionGate inboundSessionGate;
private final Object receiveLock = new Object(); private final Object receiveLock = new Object();
private ServerSocket serverSocket; private ServerSocket serverSocket;
@@ -83,6 +85,11 @@ public class TcpCastTransport implements CastTransport {
this.framingContext = context; this.framingContext = context;
} }
@Override
public void setInboundSessionGate(CastSessionGate gate) {
this.inboundSessionGate = gate;
}
@Override @Override
public void send(byte type, byte[] payload) throws IOException { public void send(byte type, byte[] payload) throws IOException {
synchronized (sendLock) { synchronized (sendLock) {
@@ -133,7 +140,83 @@ public class TcpCastTransport implements CastTransport {
} }
private CastProtocol.Message readFramedMessage() throws IOException { private CastProtocol.Message readFramedMessage() throws IOException {
return CastPacketFramer.readFramedMessage(bufferedIn); if (inboundSessionGate == null) {
return CastPacketFramer.readFramedMessage(bufferedIn);
}
byte[] magicBuf = readFully(bufferedIn, 4);
if (magicBuf == null) {
return null;
}
int magic = (magicBuf[0] & 0xff) | ((magicBuf[1] & 0xff) << 8)
| ((magicBuf[2] & 0xff) << 16) | ((magicBuf[3] & 0xff) << 24);
if (magic != CastProtoHeader.MAGIC) {
throw new IOException("Bad magic on wire: 0x" + Integer.toHexString(magic));
}
byte[] restHdr = readFully(bufferedIn, CastProtoHeader.HEADER_SIZE_V1 - 4);
if (restHdr == null) {
return null;
}
byte[] headerBytes = new byte[CastProtoHeader.HEADER_SIZE_V1];
System.arraycopy(magicBuf, 0, headerBytes, 0, 4);
System.arraycopy(restHdr, 0, headerBytes, 4, restHdr.length);
CastProtoHeader hdr = CastProtoHeader.decode(headerBytes, 0, headerBytes.length);
if (!hdr.verifyChecksum()) {
throw new IOException("Header checksum mismatch");
}
if (!inboundSessionGate.accept(hdr)) {
skipMessageBody(in);
return null;
}
byte type = (byte) in.read();
if (type < 0) {
return null;
}
byte[] lenBuf = readFully(bufferedIn, 4);
if (lenBuf == null) {
return null;
}
int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
| ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
if (len < 0 || len > 4 * 1024 * 1024) {
throw new IOException("Invalid payload length: " + len);
}
byte[] payload = len == 0 ? new byte[0] : readFully(bufferedIn, len);
if (payload == null && len > 0) {
return null;
}
return new CastProtocol.Message(type, payload);
}
private static void skipMessageBody(DataInputStream in) throws IOException {
byte type = (byte) in.read();
if (type < 0) {
return;
}
byte[] lenBuf = readFully(in, 4);
if (lenBuf == null) {
return;
}
int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
| ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
if (len > 0 && len <= 4 * 1024 * 1024) {
in.skipBytes(len);
}
}
private static byte[] readFully(InputStream in, int n) throws IOException {
byte[] buf = new byte[n];
int off = 0;
while (off < n) {
int r = in.read(buf, off, n - off);
if (r < 0) {
if (off == 0) {
return null;
}
throw new java.io.EOFException("Short read");
}
off += r;
}
return buf;
} }
private boolean waitForHeader(int timeoutMs) throws IOException { private boolean waitForHeader(int timeoutMs) throws IOException {

View File

@@ -1,6 +1,9 @@
package com.foxx.androidcast.network; package com.foxx.androidcast.network;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.network.transport.StreamProtectionEngine;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportStatsProvider;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@@ -14,7 +17,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** Best-effort message transport over UDP with fragmentation. */ /** Best-effort message transport over UDP with fragmentation. */
public class UdpCastTransport implements CastTransport { public class UdpCastTransport implements CastTransport, TransportStatsProvider {
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'}; private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */ /** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
private static final int HEADER = 17; private static final int HEADER = 17;
@@ -26,7 +29,11 @@ public class UdpCastTransport implements CastTransport {
private volatile boolean listening; private volatile boolean listening;
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>(); private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
private final TransportLossStats lossStats = new TransportLossStats();
private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop();
private CastFramingContext framingContext; private CastFramingContext framingContext;
private CastSessionGate inboundSessionGate;
private static final long REASSEMBLY_TIMEOUT_MS = 3_000;
public boolean isListening() { public boolean isListening() {
return listening && socket != null && !socket.isClosed(); return listening && socket != null && !socket.isClosed();
@@ -71,6 +78,24 @@ public class UdpCastTransport implements CastTransport {
this.framingContext = context; this.framingContext = context;
} }
@Override
public void setInboundSessionGate(CastSessionGate gate) {
this.inboundSessionGate = gate;
}
/** Select protection stub (default none). FEC/NACK are not active yet. */
public void setProtectionEngine(StreamProtectionEngine engine) {
protectionEngine = engine != null ? engine : StreamProtectionEngine.noop();
synchronized (lossStats) {
lossStats.protectionMode = protectionEngine.mode();
}
}
@Override
public TransportLossStats getLossStats() {
return lossStats.copy();
}
@Override @Override
public void send(byte type, byte[] payload) throws IOException { public void send(byte type, byte[] payload) throws IOException {
if (peer == null) { if (peer == null) {
@@ -85,6 +110,7 @@ public class UdpCastTransport implements CastTransport {
} else { } else {
body = payload == null ? new byte[0] : payload; body = payload == null ? new byte[0] : payload;
} }
body = protectionEngine.onOutboundPayload(type, body);
if (body.length <= MAX_RELIABLE_BYTES) { if (body.length <= MAX_RELIABLE_BYTES) {
sendOnce(wireType, body); sendOnce(wireType, body);
sendOnce(wireType, body); sendOnce(wireType, body);
@@ -103,6 +129,9 @@ public class UdpCastTransport implements CastTransport {
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length); byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer); DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
socket.send(dp); socket.send(dp);
synchronized (lossStats) {
lossStats.datagramsSent++;
}
} }
} }
@@ -113,12 +142,16 @@ public class UdpCastTransport implements CastTransport {
} }
long deadline = System.currentTimeMillis() + timeoutMs; long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
expireStaleReassembly();
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis()); int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
socket.setSoTimeout(remaining); socket.setSoTimeout(remaining);
try { try {
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64]; byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
DatagramPacket dp = new DatagramPacket(buf, buf.length); DatagramPacket dp = new DatagramPacket(buf, buf.length);
socket.receive(dp); socket.receive(dp);
synchronized (lossStats) {
lossStats.datagramsReceived++;
}
if (peer == null) { if (peer == null) {
peer = new InetSocketAddress(dp.getAddress(), dp.getPort()); peer = new InetSocketAddress(dp.getAddress(), dp.getPort());
} }
@@ -132,15 +165,38 @@ public class UdpCastTransport implements CastTransport {
return null; return null;
} }
private void expireStaleReassembly() {
long now = System.currentTimeMillis();
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
Reassembly r = e.getValue();
if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) {
pending.remove(e.getKey());
synchronized (lossStats) {
lossStats.reassemblyIncomplete++;
}
protectionEngine.onNackStub(e.getKey());
}
}
}
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException { private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
if (length < HEADER) { if (length < HEADER) {
synchronized (lossStats) {
lossStats.datagramsInvalid++;
}
return null; return null;
} }
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
if (data[i] != MAGIC[i]) { if (data[i] != MAGIC[i]) {
synchronized (lossStats) {
lossStats.datagramsInvalid++;
}
return null; return null;
} }
} }
synchronized (lossStats) {
lossStats.fragmentsReceived++;
}
byte type = data[4]; byte type = data[4];
int messageId = readInt(data, 5); int messageId = readInt(data, 5);
int fragIndex = readShort(data, 9); int fragIndex = readShort(data, 9);
@@ -152,32 +208,68 @@ public class UdpCastTransport implements CastTransport {
CastProtocol.Message raw; CastProtocol.Message raw;
if (fragCount <= 1) { if (fragCount <= 1) {
byte[] body;
if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) { if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) {
byte[] trimmed = new byte[totalLen]; body = new byte[totalLen];
System.arraycopy(chunk, 0, trimmed, 0, totalLen); System.arraycopy(chunk, 0, body, 0, totalLen);
raw = new CastProtocol.Message(type, trimmed);
} else { } else {
raw = new CastProtocol.Message(type, chunk); body = chunk;
} }
body = protectionEngine.onInboundPayload(type, body);
raw = new CastProtocol.Message(type, body);
return unwrapWire(raw); return unwrapWire(raw);
} }
Reassembly r = pending.get(messageId); Reassembly existing = pending.get(messageId);
if (r == null || r.fragCount != fragCount || r.totalLen != totalLen) { if (existing != null && (existing.fragCount != fragCount || existing.totalLen != totalLen)) {
pending.remove(messageId);
synchronized (lossStats) {
lossStats.reassemblySuperseded++;
}
existing = null;
}
Reassembly r = existing;
if (r == null) {
r = new Reassembly(fragCount, totalLen); r = new Reassembly(fragCount, totalLen);
pending.put(messageId, r); pending.put(messageId, r);
} }
r.put(fragIndex, chunk, payloadLen); if (!r.put(fragIndex, chunk, payloadLen)) {
synchronized (lossStats) {
lossStats.fragmentsDuplicate++;
}
return null;
}
if (!r.complete()) { if (!r.complete()) {
return null; return null;
} }
pending.remove(messageId); pending.remove(messageId);
return unwrapWire(new CastProtocol.Message(type, r.assemble())); synchronized (lossStats) {
lossStats.reassemblyCompleted++;
}
byte[] assembled = r.assemble();
assembled = protectionEngine.onInboundPayload(type, assembled);
return unwrapWire(new CastProtocol.Message(type, assembled));
} }
private static CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException { private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
if (raw.type == CastProtocol.MSG_WIRE_PACKET) { if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
return CastPacketFramer.unframe(raw.payload); if (inboundSessionGate != null) {
CastProtoHeader hdr = CastPacketFramer.readHeader(raw.payload);
if (!inboundSessionGate.accept(hdr)) {
synchronized (lossStats) {
lossStats.wirePacketsDropped++;
}
return null;
}
}
try {
return CastPacketFramer.unframe(raw.payload);
} catch (IOException e) {
synchronized (lossStats) {
lossStats.malformedPackets++;
}
throw e;
}
} }
return raw; return raw;
} }
@@ -242,6 +334,7 @@ public class UdpCastTransport implements CastTransport {
private static final class Reassembly { private static final class Reassembly {
final int fragCount; final int fragCount;
final int totalLen; final int totalLen;
final long startedMs;
final byte[][] parts; final byte[][] parts;
final int[] sizes; final int[] sizes;
int received; int received;
@@ -249,19 +342,21 @@ public class UdpCastTransport implements CastTransport {
Reassembly(int fragCount, int totalLen) { Reassembly(int fragCount, int totalLen) {
this.fragCount = fragCount; this.fragCount = fragCount;
this.totalLen = totalLen; this.totalLen = totalLen;
this.startedMs = System.currentTimeMillis();
this.parts = new byte[fragCount][]; this.parts = new byte[fragCount][];
this.sizes = new int[fragCount]; this.sizes = new int[fragCount];
} }
void put(int index, byte[] data, int len) { boolean put(int index, byte[] data, int len) {
if (index < 0 || index >= fragCount || parts[index] != null) { if (index < 0 || index >= fragCount || parts[index] != null) {
return; return false;
} }
byte[] copy = new byte[len]; byte[] copy = new byte[len];
System.arraycopy(data, 0, copy, 0, len); System.arraycopy(data, 0, copy, 0, len);
parts[index] = copy; parts[index] = copy;
sizes[index] = len; sizes[index] = len;
received++; received++;
return true;
} }
boolean complete() { boolean complete() {

View File

@@ -7,7 +7,7 @@ public class NetworkControlAdvice {
public boolean sendStats; public boolean sendStats;
public boolean sendControl; public boolean sendControl;
public int suggestedBitrateKbps; public int suggestedBitrateKbps;
public String suggestedTransport = CastConfig.TRANSPORT_TCP; public String suggestedTransport = CastConfig.DEFAULT_TRANSPORT;
public boolean switchTransport; public boolean switchTransport;
public int congestionLevel; public int congestionLevel;

View File

@@ -27,6 +27,28 @@ public class NetworkStatsSnapshot {
public float audioCapturePeakDb; public float audioCapturePeakDb;
public float audioCaptureRmsDb; public float audioCaptureRmsDb;
/** v4: UDP + app-layer loss (see {@link com.foxx.androidcast.network.transport.TransportLossStats}). */
public long udpDatagramsRx;
public long udpDatagramsTx;
public long udpDatagramsInvalid;
public long udpFragmentsRx;
public long udpFragmentsDuplicate;
public long udpReassemblyOk;
public long udpReassemblyLost;
public long udpWireDropped;
public long udpMalformed;
public int droppedAudioFrames;
public long recvVideoPackets;
public long recvAudioPackets;
public long recvVideoSkippedKey;
public long recvVideoDecodeErrors;
public long recvAudioDecodeErrors;
public long recvAudioQueueDrops;
public long recvDamagedVideoFrames;
public String protectionMode = "NONE";
public long fecStubPackets;
public long nackStubRequests;
public NetworkStatsSnapshot copy() { public NetworkStatsSnapshot copy() {
NetworkStatsSnapshot c = new NetworkStatsSnapshot(); NetworkStatsSnapshot c = new NetworkStatsSnapshot();
c.sequence = sequence; c.sequence = sequence;
@@ -49,6 +71,26 @@ public class NetworkStatsSnapshot {
c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks; c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks;
c.audioCapturePeakDb = audioCapturePeakDb; c.audioCapturePeakDb = audioCapturePeakDb;
c.audioCaptureRmsDb = audioCaptureRmsDb; c.audioCaptureRmsDb = audioCaptureRmsDb;
c.udpDatagramsRx = udpDatagramsRx;
c.udpDatagramsTx = udpDatagramsTx;
c.udpDatagramsInvalid = udpDatagramsInvalid;
c.udpFragmentsRx = udpFragmentsRx;
c.udpFragmentsDuplicate = udpFragmentsDuplicate;
c.udpReassemblyOk = udpReassemblyOk;
c.udpReassemblyLost = udpReassemblyLost;
c.udpWireDropped = udpWireDropped;
c.udpMalformed = udpMalformed;
c.droppedAudioFrames = droppedAudioFrames;
c.recvVideoPackets = recvVideoPackets;
c.recvAudioPackets = recvAudioPackets;
c.recvVideoSkippedKey = recvVideoSkippedKey;
c.recvVideoDecodeErrors = recvVideoDecodeErrors;
c.recvAudioDecodeErrors = recvAudioDecodeErrors;
c.recvAudioQueueDrops = recvAudioQueueDrops;
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
c.protectionMode = protectionMode;
c.fecStubPackets = fecStubPackets;
c.nackStubRequests = nackStubRequests;
return c; return c;
} }
} }

View File

@@ -33,7 +33,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS; outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS;
outbound.sendControl = false; outbound.sendControl = false;
outbound.suggestedBitrateKbps = 0; outbound.suggestedBitrateKbps = 0;
outbound.suggestedTransport = CastConfig.TRANSPORT_TCP; outbound.suggestedTransport = CastConfig.DEFAULT_TRANSPORT;
outbound.switchTransport = false; outbound.switchTransport = false;
outbound.congestionLevel = 0; outbound.congestionLevel = 0;
if (outbound.sendStats) { if (outbound.sendStats) {

View File

@@ -0,0 +1,50 @@
package com.foxx.androidcast.network.quic;
import android.content.Context;
import com.google.android.gms.net.CronetProviderInstaller;
import com.google.android.gms.tasks.Tasks;
import org.chromium.net.CronetEngine;
import org.chromium.net.ExperimentalCronetEngine;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** Shared Cronet engine with QUIC / HTTP3 enabled for experimental cast transport. */
public final class CronetEngineProvider {
private static final AtomicReference<CronetEngine> ENGINE = new AtomicReference<>();
private CronetEngineProvider() {}
public static CronetEngine getEngine(Context context) throws Exception {
CronetEngine existing = ENGINE.get();
if (existing != null) {
return existing;
}
synchronized (CronetEngineProvider.class) {
existing = ENGINE.get();
if (existing != null) {
return existing;
}
Context app = context.getApplicationContext();
Tasks.await(CronetProviderInstaller.installProvider(app), 30, TimeUnit.SECONDS);
ExperimentalCronetEngine.Builder builder =
new ExperimentalCronetEngine.Builder(app)
.enableQuic(true)
.enableHttp2(true)
.setStoragePath(app.getCacheDir().getAbsolutePath())
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 256 * 1024);
CronetEngine built = builder.build();
ENGINE.set(built);
return built;
}
}
public static void shutdown() {
CronetEngine e = ENGINE.getAndSet(null);
if (e != null) {
e.shutdown();
}
}
}

View File

@@ -0,0 +1,138 @@
package com.foxx.androidcast.network.quic;
import android.content.Context;
import android.util.Log;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.network.CastFramingContext;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.TcpCastTransport;
import com.foxx.androidcast.network.UdpCastTransport;
import org.chromium.net.CronetEngine;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Experimental QUIC cast transport (Cronet stack).
* <p>
* Cronet on Android is a QUIC/HTTP3 <em>client</em> only. This transport uses:
* <ul>
* <li>{@link CronetEngine} with QUIC enabled (shared via {@link CronetEngineProvider})</li>
* <li>Framed cast messages on {@link CastConfig#QUIC_PORT} over UDP (same as legacy ACUD framing)</li>
* <li>TCP fallback on the same port if UDP bind fails</li>
* </ul>
* A future revision can move the data plane to Cronet {@code BidirectionalStream} once an HTTP/3
* terminator is available on the receiver.
*/
public final class QuicCronetCastTransport implements CastTransport {
private static final String TAG = "QuicCronetTransport";
private final Context appContext;
private final UdpCastTransport udp = new UdpCastTransport();
private final TcpCastTransport tcpFallback = new TcpCastTransport();
private final AtomicBoolean usingTcp = new AtomicBoolean(false);
private CronetEngine engine;
public QuicCronetCastTransport(Context context) {
this.appContext = context.getApplicationContext();
}
private CastTransport active() {
return usingTcp.get() ? tcpFallback : udp;
}
@Override
public void setFramingContext(CastFramingContext context) {
udp.setFramingContext(context);
tcpFallback.setFramingContext(context);
}
@Override
public void setInboundSessionGate(com.foxx.androidcast.network.CastSessionGate gate) {
udp.setInboundSessionGate(gate);
tcpFallback.setInboundSessionGate(gate);
}
private void ensureCronet() {
if (engine != null) {
return;
}
try {
engine = CronetEngineProvider.getEngine(appContext);
Log.i(TAG, "Cronet engine ready (QUIC/HTTP3 enabled for experimental transport)");
} catch (Exception e) {
Log.w(TAG, "Cronet engine unavailable: " + e.getMessage());
}
}
@Override
public void prepareListen(int port) throws IOException {
ensureCronet();
int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
try {
udp.prepareListen(quicPort);
usingTcp.set(false);
Log.i(TAG, "QUIC listen UDP :" + quicPort);
} catch (IOException e) {
Log.w(TAG, "QUIC UDP listen failed, TCP fallback :" + quicPort, e);
tcpFallback.prepareListen(quicPort);
usingTcp.set(true);
}
}
@Override
public void acceptClient() throws IOException {
active().acceptClient();
}
@Override
public void listen(int port) throws IOException {
prepareListen(port);
acceptClient();
}
@Override
public void connect(String host, int port) throws IOException {
ensureCronet();
int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
try {
udp.connect(host, quicPort);
usingTcp.set(false);
Log.i(TAG, "QUIC connect UDP " + host + ":" + quicPort);
} catch (IOException e) {
Log.w(TAG, "QUIC UDP connect failed, TCP fallback", e);
tcpFallback.connect(host, quicPort);
usingTcp.set(true);
}
}
@Override
public void send(byte type, byte[] payload) throws IOException {
active().send(type, payload);
}
@Override
public CastProtocol.Message receive(int timeoutMs) throws IOException {
return active().receive(timeoutMs);
}
@Override
public String getModeLabel() {
String base = usingTcp.get() ? "QUIC(exp/TCP)" : "QUIC(exp/UDP)";
return engine != null ? base + "+Cronet" : base;
}
@Override
public void releaseConnection() {
active().releaseConnection();
}
@Override
public void close() {
udp.close();
tcpFallback.close();
}
}

View File

@@ -0,0 +1,95 @@
package com.foxx.androidcast.network.transport;
/**
* Hook for future FEC / NACK over UDP. Current builds use {@link #noop()} only.
*/
public interface StreamProtectionEngine {
StreamProtectionMode mode();
/** Called before UDP payload is fragmented (outbound). */
byte[] onOutboundPayload(byte type, byte[] payload);
/** Called when a reassembled UDP message is ready (inbound). */
byte[] onInboundPayload(byte type, byte[] payload);
/** Future: process FEC repair block (stub). */
default void onFecBlockStub(byte[] block) {
}
/** Future: sender handles NACK for messageId (stub). */
default void onNackStub(int messageId) {
}
static StreamProtectionEngine noop() {
return new StreamProtectionEngine() {
@Override
public StreamProtectionMode mode() {
return StreamProtectionMode.NONE;
}
@Override
public byte[] onOutboundPayload(byte type, byte[] payload) {
return payload;
}
@Override
public byte[] onInboundPayload(byte type, byte[] payload) {
return payload;
}
};
}
/** Placeholder for upcoming FEC — counts invocations only. */
static StreamProtectionEngine fecStub(TransportLossStats stats) {
return new StreamProtectionEngine() {
@Override
public StreamProtectionMode mode() {
return StreamProtectionMode.FEC_STUB;
}
@Override
public byte[] onOutboundPayload(byte type, byte[] payload) {
if (stats != null) {
synchronized (stats) {
stats.fecPacketsStub++;
}
}
return payload;
}
@Override
public byte[] onInboundPayload(byte type, byte[] payload) {
return payload;
}
};
}
/** Placeholder for upcoming NACK — no retransmit yet. */
static StreamProtectionEngine nackStub(TransportLossStats stats) {
return new StreamProtectionEngine() {
@Override
public StreamProtectionMode mode() {
return StreamProtectionMode.NACK_STUB;
}
@Override
public byte[] onOutboundPayload(byte type, byte[] payload) {
return payload;
}
@Override
public byte[] onInboundPayload(byte type, byte[] payload) {
return payload;
}
@Override
public void onNackStub(int messageId) {
if (stats != null) {
synchronized (stats) {
stats.nackRequestsStub++;
}
}
}
};
}
}

View File

@@ -0,0 +1,26 @@
package com.foxx.androidcast.network.transport;
/** Planned LAN cast reliability features (stubs until implemented). */
public enum StreamProtectionMode {
NONE,
/** Forward-error correction over UDP (not implemented). */
FEC_STUB,
/** Negative acknowledgement / retransmit requests (not implemented). */
NACK_STUB;
public boolean isStub() {
return this == FEC_STUB || this == NACK_STUB;
}
public String overlayLabel() {
switch (this) {
case FEC_STUB:
return "FEC (stub)";
case NACK_STUB:
return "NACK (stub)";
case NONE:
default:
return "none";
}
}
}

View File

@@ -0,0 +1,97 @@
package com.foxx.androidcast.network.transport;
/**
* Per-transport loss / damage counters (UDP datagrams, reassembly, app-layer drops).
* Thread-safe increments; copy for stats exchange.
*/
public final class TransportLossStats {
public long datagramsSent;
public long datagramsReceived;
public long datagramsInvalid;
public long fragmentsReceived;
public long fragmentsDuplicate;
public long reassemblyCompleted;
public long reassemblyIncomplete;
public long reassemblySuperseded;
public long wirePacketsDropped;
public long malformedPackets;
public int sendDroppedVideoP;
public int sendDroppedAudio;
public int sendQueueDepth;
public long recvVideoPackets;
public long recvAudioPackets;
public long recvVideoSkippedAwaitingKey;
public long recvVideoDecodeErrors;
public long recvAudioDecodeErrors;
public long recvAudioQueueDrops;
public long recvDamagedVideoFrames;
public StreamProtectionMode protectionMode = StreamProtectionMode.NONE;
public long fecPacketsStub;
public long nackRequestsStub;
public long nackRetransmitsStub;
public synchronized void addFrom(TransportLossStats other) {
if (other == null) {
return;
}
datagramsSent += other.datagramsSent;
datagramsReceived += other.datagramsReceived;
datagramsInvalid += other.datagramsInvalid;
fragmentsReceived += other.fragmentsReceived;
fragmentsDuplicate += other.fragmentsDuplicate;
reassemblyCompleted += other.reassemblyCompleted;
reassemblyIncomplete += other.reassemblyIncomplete;
reassemblySuperseded += other.reassemblySuperseded;
wirePacketsDropped += other.wirePacketsDropped;
malformedPackets += other.malformedPackets;
sendDroppedVideoP += other.sendDroppedVideoP;
sendDroppedAudio += other.sendDroppedAudio;
recvVideoPackets += other.recvVideoPackets;
recvAudioPackets += other.recvAudioPackets;
recvVideoSkippedAwaitingKey += other.recvVideoSkippedAwaitingKey;
recvVideoDecodeErrors += other.recvVideoDecodeErrors;
recvAudioDecodeErrors += other.recvAudioDecodeErrors;
recvAudioQueueDrops += other.recvAudioQueueDrops;
recvDamagedVideoFrames += other.recvDamagedVideoFrames;
fecPacketsStub += other.fecPacketsStub;
nackRequestsStub += other.nackRequestsStub;
nackRetransmitsStub += other.nackRetransmitsStub;
}
public synchronized TransportLossStats copy() {
TransportLossStats c = new TransportLossStats();
c.datagramsSent = datagramsSent;
c.datagramsReceived = datagramsReceived;
c.datagramsInvalid = datagramsInvalid;
c.fragmentsReceived = fragmentsReceived;
c.fragmentsDuplicate = fragmentsDuplicate;
c.reassemblyCompleted = reassemblyCompleted;
c.reassemblyIncomplete = reassemblyIncomplete;
c.reassemblySuperseded = reassemblySuperseded;
c.wirePacketsDropped = wirePacketsDropped;
c.malformedPackets = malformedPackets;
c.sendDroppedVideoP = sendDroppedVideoP;
c.sendDroppedAudio = sendDroppedAudio;
c.sendQueueDepth = sendQueueDepth;
c.recvVideoPackets = recvVideoPackets;
c.recvAudioPackets = recvAudioPackets;
c.recvVideoSkippedAwaitingKey = recvVideoSkippedAwaitingKey;
c.recvVideoDecodeErrors = recvVideoDecodeErrors;
c.recvAudioDecodeErrors = recvAudioDecodeErrors;
c.recvAudioQueueDrops = recvAudioQueueDrops;
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
c.protectionMode = protectionMode;
c.fecPacketsStub = fecPacketsStub;
c.nackRequestsStub = nackRequestsStub;
c.nackRetransmitsStub = nackRetransmitsStub;
return c;
}
/** Estimated UDP loss: incomplete reassembly + invalid, not double-counting fragments. */
public synchronized long estimatedUdpLossEvents() {
return reassemblyIncomplete + reassemblySuperseded + datagramsInvalid + malformedPackets;
}
}

View File

@@ -0,0 +1,42 @@
package com.foxx.androidcast.network.transport;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
/** Copies {@link TransportLossStats} into {@link NetworkStatsSnapshot} for overlay / heartbeat. */
public final class TransportLossStatsBridge {
private TransportLossStatsBridge() {}
public static void applyTo(NetworkStatsSnapshot s, TransportLossStats loss) {
if (s == null || loss == null) {
return;
}
synchronized (loss) {
s.udpDatagramsRx = loss.datagramsReceived;
s.udpDatagramsTx = loss.datagramsSent;
s.udpDatagramsInvalid = loss.datagramsInvalid;
s.udpFragmentsRx = loss.fragmentsReceived;
s.udpFragmentsDuplicate = loss.fragmentsDuplicate;
s.udpReassemblyOk = loss.reassemblyCompleted;
s.udpReassemblyLost = loss.reassemblyIncomplete + loss.reassemblySuperseded;
s.udpWireDropped = loss.wirePacketsDropped;
s.udpMalformed = loss.malformedPackets;
s.droppedPFrames = Math.max(s.droppedPFrames, loss.sendDroppedVideoP);
s.droppedAudioFrames = loss.sendDroppedAudio;
s.encodeQueueDepth = Math.max(s.encodeQueueDepth, loss.sendQueueDepth);
s.recvVideoPackets = loss.recvVideoPackets;
s.recvAudioPackets = loss.recvAudioPackets;
s.recvVideoSkippedKey = loss.recvVideoSkippedAwaitingKey;
s.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
s.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
s.recvAudioQueueDrops = loss.recvAudioQueueDrops;
s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
s.protectionMode = loss.protectionMode.name();
s.fecStubPackets = loss.fecPacketsStub;
s.nackStubRequests = loss.nackRequestsStub;
long lossEvents = loss.estimatedUdpLossEvents();
if (loss.datagramsReceived > 0 && lossEvents > 0) {
s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived);
}
}
}
}

View File

@@ -0,0 +1,6 @@
package com.foxx.androidcast.network.transport;
/** Transports that expose {@link TransportLossStats}. */
public interface TransportStatsProvider {
TransportLossStats getLossStats();
}

View File

@@ -0,0 +1,62 @@
package com.foxx.androidcast.network.webrtc;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastTransport;
import java.io.IOException;
/**
* Future WebRTC transport (libwebrtc or pion-backed). Not wired yet — use {@link #createStub()}.
*/
public interface WebRtcCastTransport extends CastTransport {
/** Signaling / ICE configuration for upcoming integration. */
interface SignalingConfig {
String getSignalingUrl();
String[] getIceServers();
}
/** Attach when integrating real PeerConnection / DataChannel. */
void attachPeer(WebRtcPeerAdapter peer);
/**
* Placeholder until libwebrtc or pion transport is connected.
*/
static WebRtcCastTransport createStub() {
return new WebRtcCastTransportStub();
}
final class WebRtcCastTransportStub implements WebRtcCastTransport {
@Override
public void attachPeer(WebRtcPeerAdapter peer) {}
@Override
public void listen(int port) throws IOException {
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
}
@Override
public void connect(String host, int port) throws IOException {
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
}
@Override
public void send(byte type, byte[] payload) throws IOException {
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
}
@Override
public CastProtocol.Message receive(int timeoutMs) throws IOException {
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
}
@Override
public String getModeLabel() {
return "WebRTC (stub)";
}
@Override
public void close() {}
}
}

View File

@@ -0,0 +1,37 @@
package com.foxx.androidcast.network.webrtc;
/**
* Abstraction over future {@code org.webrtc.PeerConnection} or pion peer APIs.
* Implementations will map cast frames to SCTP DataChannel or encoded tracks.
*/
public interface WebRtcPeerAdapter {
interface Observer {
void onIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
void onConnectionStateChanged(WebRtcConnectionState state);
void onDataChannelMessage(byte[] payload);
void onEncodedVideoFrame(long ptsUs, boolean keyFrame, byte[] data);
}
enum WebRtcConnectionState {
NEW, CONNECTING, CONNECTED, DISCONNECTED, FAILED, CLOSED
}
void setObserver(Observer observer);
void createOffer();
void createAnswer(String remoteDescription);
void setRemoteDescription(String sdp, boolean isOffer);
void addIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
/** Send opaque cast protocol bytes on a labeled DataChannel. */
void sendDataChannel(byte[] payload);
void close();
}

View File

@@ -0,0 +1,19 @@
package com.foxx.androidcast.network.webrtc;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.network.CastTransport;
/**
* Factory for WebRTC-backed cast transport (stub today; swap implementation for libwebrtc / pion).
*/
public final class WebRtcTransportFactory {
private WebRtcTransportFactory() {}
public static boolean isTransportId(String transport) {
return CastConfig.TRANSPORT_WEBRTC.equals(transport);
}
public static CastTransport create() {
return WebRtcCastTransport.createStub();
}
}

View File

@@ -7,6 +7,7 @@ import android.media.AudioManager;
import android.media.AudioTrack; import android.media.AudioTrack;
import android.media.MediaCodec; import android.media.MediaCodec;
import android.media.MediaFormat; import android.media.MediaFormat;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.HandlerThread; import android.os.HandlerThread;
import android.util.Log; import android.util.Log;
@@ -36,6 +37,7 @@ public class AudioDecoder {
public long pcmSilentBlocks; public long pcmSilentBlocks;
public long pcmAudibleBlocks; public long pcmAudibleBlocks;
public long decodeErrors; public long decodeErrors;
public long queueDrops;
public float lastPeak; public float lastPeak;
public float lastRms; public float lastRms;
} }
@@ -43,7 +45,8 @@ public class AudioDecoder {
private final Context appContext; private final Context appContext;
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl"); private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
private Handler worker; private Handler worker;
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(128); private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(256);
private long playbackEpochUs = -1;
private final Stats stats = new Stats(); private final Stats stats = new Stats();
private MediaCodec codec; private MediaCodec codec;
@@ -130,6 +133,7 @@ public class AudioDecoder {
private void startDrainThread() { private void startDrainThread() {
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain"); drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
drainThread.setPriority(Thread.MAX_PRIORITY - 2);
drainThread.start(); drainThread.start();
} }
@@ -138,7 +142,7 @@ public class AudioDecoder {
? AudioFormat.CHANNEL_OUT_STEREO ? AudioFormat.CHANNEL_OUT_STEREO
: AudioFormat.CHANNEL_OUT_MONO; : AudioFormat.CHANNEL_OUT_MONO;
int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT); int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
int bufSize = Math.max(minBuf * 8, 65536); int bufSize = Math.max(minBuf * 16, 131072);
track = new AudioTrack.Builder() track = new AudioTrack.Builder()
.setAudioAttributes(new AudioAttributes.Builder() .setAudioAttributes(new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA) .setUsage(AudioAttributes.USAGE_MEDIA)
@@ -164,8 +168,11 @@ public class AudioDecoder {
} }
stats.framesQueued++; stats.framesQueued++;
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
stats.queueDrops++;
inputQueue.poll(); inputQueue.poll();
inputQueue.offer(new PendingFrame(ptsUs, data.clone())); if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
stats.queueDrops++;
}
} }
} }
@@ -241,7 +248,8 @@ public class AudioDecoder {
} else { } else {
stats.pcmAudibleBlocks++; stats.pcmAudibleBlocks++;
} }
int written = writePcm(pcm); pacePlayback(info.presentationTimeUs);
int written = writePcmBlocking(pcm);
if (written > 0) { if (written > 0) {
stats.pcmBytesWritten += written; stats.pcmBytesWritten += written;
if (renderCallback != null) { if (renderCallback != null) {
@@ -253,16 +261,42 @@ public class AudioDecoder {
} }
} }
private int writePcm(byte[] pcm) { private void pacePlayback(long ptsUs) {
long nowUs = System.nanoTime() / 1000L;
if (playbackEpochUs < 0) {
playbackEpochUs = nowUs - ptsUs;
return;
}
long dueUs = playbackEpochUs + ptsUs;
long aheadUs = dueUs - nowUs;
if (aheadUs > 120_000L) {
try {
Thread.sleep(aheadUs / 1000L, (int) ((aheadUs % 1000L) * 1000L));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private int writePcmBlocking(byte[] pcm) {
int total = 0; int total = 0;
int offset = 0; int offset = 0;
while (offset < pcm.length) { int idle = 0;
while (offset < pcm.length && idle < 80) {
int w = track.write(pcm, offset, pcm.length - offset); int w = track.write(pcm, offset, pcm.length - offset);
if (w <= 0) { if (w > 0) {
total += w;
offset += w;
idle = 0;
continue;
}
idle++;
try {
Thread.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break; break;
} }
total += w;
offset += w;
} }
return total; return total;
} }
@@ -275,7 +309,11 @@ public class AudioDecoder {
} }
} }
public void release() { /** Stops playback immediately and drops queued AAC (sender ended cast). */
public void flushAndStop() {
draining = false;
configured = false;
inputQueue.clear();
final Object lock = new Object(); final Object lock = new Object();
worker.post(() -> { worker.post(() -> {
releaseOnWorker(); releaseOnWorker();
@@ -285,21 +323,26 @@ public class AudioDecoder {
}); });
synchronized (lock) { synchronized (lock) {
try { try {
lock.wait(2000); lock.wait(500);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
} }
} }
public void release() {
flushAndStop();
}
private void releaseOnWorker() { private void releaseOnWorker() {
draining = false; draining = false;
configured = false; configured = false;
playbackEpochUs = -1;
inputQueue.clear(); inputQueue.clear();
if (drainThread != null) { if (drainThread != null) {
drainThread.interrupt(); drainThread.interrupt();
try { try {
drainThread.join(1500); drainThread.join(400);
} catch (InterruptedException ignored) { } catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
@@ -315,12 +358,26 @@ public class AudioDecoder {
} }
if (track != null) { if (track != null) {
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
track.pause();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
track.flush();
}
track.stop(); track.stop();
} catch (Exception ignored) { } catch (Exception ignored) {
} }
track.release(); track.release();
track = null; track = null;
} }
abandonAudioFocus();
}
private void abandonAudioFocus() {
AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
if (am != null) {
am.abandonAudioFocus(null);
}
} }
public void releaseAndQuit() { public void releaseAndQuit() {

View File

@@ -22,7 +22,11 @@ import com.foxx.androidcast.R;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter; import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
import com.foxx.androidcast.discovery.DiscoveryManager; import com.foxx.androidcast.discovery.DiscoveryManager;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.util.Arrays; import java.util.Arrays;
@@ -80,7 +84,10 @@ public class ReceiverCastService extends Service {
private CastSettings remoteCastSettings; private CastSettings remoteCastSettings;
private NetworkStatsSnapshot lastLocalStats; private NetworkStatsSnapshot lastLocalStats;
private boolean streamIdle; private boolean streamIdle;
private volatile boolean castEnded;
private long lastVideoFrameMs; private long lastVideoFrameMs;
private final TransportLossStats transportLoss = new TransportLossStats();
private SessionStatsRecorder sessionStatsRecorder;
private final Runnable idleWatchdog = new Runnable() { private final Runnable idleWatchdog = new Runnable() {
@Override @Override
@@ -136,6 +143,7 @@ public class ReceiverCastService extends Service {
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
CastNotifications.createChannels(this); CastNotifications.createChannels(this);
SessionStatsStore.pruneOnAppStart(this);
mainHandler.post(idleWatchdog); mainHandler.post(idleWatchdog);
} }
@@ -175,7 +183,11 @@ public class ReceiverCastService extends Service {
private void restartListeningLocked(String pin) { private void restartListeningLocked(String pin) {
stopSessionOnly(); stopSessionOnly();
castEnded = false;
resetStreamState(); resetStreamState();
pendingWidth = 0;
pendingHeight = 0;
playbackLaunched = false;
phase = Phase.LISTENING; phase = Phase.LISTENING;
acquireWakeLock(); acquireWakeLock();
startForeground(CastNotifications.ID_RECEIVER, startForeground(CastNotifications.ID_RECEIVER,
@@ -220,10 +232,10 @@ public class ReceiverCastService extends Service {
@Override @Override
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) { public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
if (reconfiguring) {
return;
}
if (awaitingKeyframe && !keyFrame) { if (awaitingKeyframe && !keyFrame) {
synchronized (transportLoss) {
transportLoss.recvVideoSkippedAwaitingKey++;
}
return; return;
} }
if (keyFrame) { if (keyFrame) {
@@ -237,7 +249,10 @@ public class ReceiverCastService extends Service {
onStreamResumedAfterIdle(); onStreamResumedAfterIdle();
} }
streamMetrics.onVideoPacket(keyFrame, frameBytes); streamMetrics.onVideoPacket(keyFrame, frameBytes);
if (videoDecoder != null && !reconfiguring) { synchronized (transportLoss) {
transportLoss.recvVideoPackets++;
}
if (videoDecoder != null) {
videoDecoder.queueFrame(ptsUs, keyFrame, data); videoDecoder.queueFrame(ptsUs, keyFrame, data);
} }
}); });
@@ -271,6 +286,9 @@ public class ReceiverCastService extends Service {
@Override @Override
public void onAudioFrame(long ptsUs, byte[] data) { public void onAudioFrame(long ptsUs, byte[] data) {
if (castEnded) {
return;
}
final int frameBytes = data != null ? data.length : 0; final int frameBytes = data != null ? data.length : 0;
final boolean accept; final boolean accept;
synchronized (ReceiverCastService.this) { synchronized (ReceiverCastService.this) {
@@ -279,7 +297,17 @@ public class ReceiverCastService extends Service {
if (accept && audioDecoder != null) { if (accept && audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data); audioDecoder.queueFrame(ptsUs, data);
} }
mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes)); mainHandler.post(() -> {
streamMetrics.onAudioPacket(frameBytes);
synchronized (transportLoss) {
transportLoss.recvAudioPackets++;
}
});
}
@Override
public void onCastEnded() {
mainHandler.postAtFrontOfQueue(ReceiverCastService.this::stopAudioPlaybackImmediate);
} }
@Override @Override
@@ -304,6 +332,13 @@ public class ReceiverCastService extends Service {
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) { private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
phase = Phase.CONNECTED; phase = Phase.CONNECTED;
castEnded = false;
if (sessionStatsRecorder == null) {
sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport());
sessionStatsRecorder.setClientCount(1);
}
sessionStatsRecorder.mergeSettings(remoteSettings);
sessionStatsRecorder.setCodecs(videoMime, "AAC");
negotiatedVideoMime = videoMime; negotiatedVideoMime = videoMime;
remoteCastSettings = remoteSettings; remoteCastSettings = remoteSettings;
streamIdle = false; streamIdle = false;
@@ -332,25 +367,32 @@ public class ReceiverCastService extends Service {
}, "AudioConsent").start(); }, "AudioConsent").start();
} }
private void stopAudioPlaybackImmediate() {
castEnded = true;
if (audioDecoder != null) {
audioDecoder.flushAndStop();
audioDecoder = new AudioDecoder(this);
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
}
Log.i(TAG, "Audio playback stopped");
}
private void onSenderDisconnected() { private void onSenderDisconnected() {
stopAudioPlaybackImmediate();
phase = Phase.LISTENING; phase = Phase.LISTENING;
audioAccepted = null; audioAccepted = null;
remoteCastSettings = null; remoteCastSettings = null;
streamIdle = false; streamIdle = false;
lastVideoFrameMs = 0; lastVideoFrameMs = 0;
negotiatedVideoMime = null; negotiatedVideoMime = null;
enterAwaitingMode(R.string.playback_stream_ended, true); pendingWidth = 0;
updateStatus(getString(R.string.waiting_sender)); pendingHeight = 0;
closePlaybackUi(); hasPendingConfig = false;
openReceiverSetupUi(); resetDecoderState();
Log.i(TAG, "Sender disconnected"); enterAwaitingMode(R.string.playback_listening, true);
} updateStatus(getString(R.string.receiver_ready, activePin, settings.getTransport().toUpperCase()));
Log.i(TAG, "Sender disconnected — listening again");
private void openReceiverSetupUi() {
Intent intent = new Intent(this, ReceiverActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(intent);
} }
private void checkStreamIdle() { private void checkStreamIdle() {
@@ -360,6 +402,9 @@ public class ReceiverCastService extends Service {
long idleMs = System.currentTimeMillis() - lastVideoFrameMs; long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) { if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) {
Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting"); Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting");
if (sessionStatsRecorder != null) {
sessionStatsRecorder.noteIdleTimeout();
}
enterAwaitingMode(R.string.playback_awaiting_stream, false); enterAwaitingMode(R.string.playback_awaiting_stream, false);
} }
} }
@@ -459,6 +504,9 @@ public class ReceiverCastService extends Service {
decoderActive = false; decoderActive = false;
notifyPlaybackDimensions(pendingWidth, pendingHeight); notifyPlaybackDimensions(pendingWidth, pendingHeight);
scheduleDecoderConfigureRetries(); scheduleDecoderConfigureRetries();
if (sessionStatsRecorder != null) {
sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
}
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight); Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
} }
@@ -504,9 +552,11 @@ public class ReceiverCastService extends Service {
return; return;
} }
discovery = new DiscoveryManager(this); discovery = new DiscoveryManager(this);
discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT, com.foxx.androidcast.network.CastTransportFactory.init(this);
int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
discovery.startAnnouncing(android.os.Build.MODEL, announcePort,
settings.getTransport(), true); settings.getTransport(), true);
Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT); Log.i(TAG, "Discovery announcing on port " + announcePort);
} }
private void stopSessionOnly() { private void stopSessionOnly() {
@@ -545,6 +595,7 @@ public class ReceiverCastService extends Service {
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) { if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
+ " surface=" + (attachedSurface != null && attachedSurface.isValid())); + " surface=" + (attachedSurface != null && attachedSurface.isValid()));
reconfiguring = false;
return; return;
} }
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) { if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
@@ -569,16 +620,24 @@ public class ReceiverCastService extends Service {
public void onFrameRendered() { public void onFrameRendered() {
ReceiverCastService.this.onFrameRendered(); ReceiverCastService.this.onFrameRendered();
} }
@Override
public void onOutputSizeChanged(int width, int height) {
ReceiverCastService.this.onDecoderOutputSize(width, height);
}
}); });
decoderActive = true; decoderActive = true;
reconfiguring = false; reconfiguring = false;
decoderWidth = pendingWidth; decoderWidth = pendingWidth;
decoderHeight = pendingHeight; decoderHeight = pendingHeight;
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight)); updateStatus(getString(R.string.playback_awaiting_stream));
broadcastStreamStarted(pendingWidth, pendingHeight); broadcastStreamStarted(pendingWidth, pendingHeight);
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight); Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
} catch (Exception e) { } catch (Exception e) {
decoderActive = false; decoderActive = false;
synchronized (transportLoss) {
transportLoss.recvVideoDecodeErrors++;
}
Log.e(TAG, "Video configure failed", e); Log.e(TAG, "Video configure failed", e);
updateStatus("Video: " + e.getMessage()); updateStatus("Video: " + e.getMessage());
} }
@@ -589,6 +648,9 @@ public class ReceiverCastService extends Service {
streamMetrics.onRenderedFrame(); streamMetrics.onRenderedFrame();
Log.i(TAG, "First frame on screen"); Log.i(TAG, "First frame on screen");
streamIdle = false; streamIdle = false;
if (pendingWidth > 0 && pendingHeight > 0) {
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
}
broadcastVideoRendering(); broadcastVideoRendering();
}); });
} }
@@ -597,8 +659,27 @@ public class ReceiverCastService extends Service {
mainHandler.post(() -> streamMetrics.onRenderedFrame()); mainHandler.post(() -> streamMetrics.onRenderedFrame());
} }
private void onDecoderOutputSize(int width, int height) {
mainHandler.post(() -> {
if (width <= 0 || height <= 0) {
return;
}
pendingWidth = width;
pendingHeight = height;
decoderWidth = width;
decoderHeight = height;
notifyPlaybackDimensions(width, height);
Log.i(TAG, "Decoder output size " + width + "x" + height);
});
}
private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) { private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
s.encodeFps = streamMetrics.getDecodeFps(); s.encodeFps = streamMetrics.getDecodeFps();
TransportLossStats snap = buildReceiverLossSnapshot();
TransportLossStatsBridge.applyTo(s, snap);
if (sessionStatsRecorder != null) {
sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer());
}
NetworkStatsSnapshot peer = streamMetrics.getLastPeer(); NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings; CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings;
com.foxx.androidcast.CastTuningEngine.EffectiveParams eff = com.foxx.androidcast.CastTuningEngine.EffectiveParams eff =
@@ -634,6 +715,34 @@ public class ReceiverCastService extends Service {
PlaybackViewState.getZoomPercent()); PlaybackViewState.getZoomPercent());
} }
private TransportLossStats buildReceiverLossSnapshot() {
TransportLossStats snap = session != null
? session.getTransportLossStats() : new TransportLossStats();
synchronized (transportLoss) {
snap.recvVideoSkippedAwaitingKey = transportLoss.recvVideoSkippedAwaitingKey;
snap.recvVideoPackets = transportLoss.recvVideoPackets;
snap.recvAudioPackets = transportLoss.recvAudioPackets;
snap.recvVideoDecodeErrors = transportLoss.recvVideoDecodeErrors;
}
if (audioDecoder != null) {
AudioDecoder.Stats ast = audioDecoder.getStats();
snap.recvAudioDecodeErrors = ast.decodeErrors;
snap.recvAudioQueueDrops = ast.queueDrops;
}
return snap;
}
private void finishSessionStatsAsync() {
final SessionStatsRecorder recorder = sessionStatsRecorder;
sessionStatsRecorder = null;
if (recorder == null) {
return;
}
recorder.setResolution(pendingWidth, pendingHeight);
recorder.mergeLoss(buildReceiverLossSnapshot());
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
}
private void attachSurface(Surface surface) { private void attachSurface(Surface surface) {
if (surface == null || !surface.isValid()) { if (surface == null || !surface.isValid()) {
Log.w(TAG, "attachSurface: invalid surface"); Log.w(TAG, "attachSurface: invalid surface");
@@ -732,6 +841,7 @@ public class ReceiverCastService extends Service {
} }
private void releaseAll() { private void releaseAll() {
finishSessionStatsAsync();
phase = Phase.IDLE; phase = Phase.IDLE;
resetStreamState(); resetStreamState();
playbackLaunched = false; playbackLaunched = false;

View File

@@ -9,8 +9,9 @@ import android.os.Handler;
import android.os.IBinder; import android.os.IBinder;
import android.os.Looper; import android.os.Looper;
import android.os.RemoteException; import android.os.RemoteException;
import android.view.SurfaceHolder; import android.graphics.SurfaceTexture;
import android.view.SurfaceView; import android.view.Surface;
import android.view.TextureView;
import android.view.View; import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
import android.animation.AnimatorSet; import android.animation.AnimatorSet;
@@ -27,6 +28,7 @@ import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.ui.AspectRatioFrameLayout; import com.foxx.androidcast.ui.AspectRatioFrameLayout;
import com.foxx.androidcast.ui.PlaybackTextureTransform;
import com.foxx.androidcast.ui.ZoomPanContainer; import com.foxx.androidcast.ui.ZoomPanContainer;
import androidx.core.text.HtmlCompat; import androidx.core.text.HtmlCompat;
@@ -35,7 +37,7 @@ import androidx.core.text.HtmlCompat;
* Fullscreen display: dancing-robot overlay while listening / waiting; * Fullscreen display: dancing-robot overlay while listening / waiting;
* video surface shown only after the first decoded frame. * video surface shown only after the first decoded frame.
*/ */
public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback { public class ReceiverPlaybackActivity extends AppCompatActivity {
public static final String EXTRA_WIDTH = "video_width"; public static final String EXTRA_WIDTH = "video_width";
public static final String EXTRA_HEIGHT = "video_height"; public static final String EXTRA_HEIGHT = "video_height";
public static final String EXTRA_FINISH = "finish_playback"; public static final String EXTRA_FINISH = "finish_playback";
@@ -43,13 +45,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message"; public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message";
private AspectRatioFrameLayout aspectContainer; private AspectRatioFrameLayout aspectContainer;
private SurfaceView surfaceView; private TextureView textureView;
private View awaitingOverlay; private View awaitingOverlay;
private TextView diagnosticsText; private TextView diagnosticsText;
private ImageView robotView; private ImageView robotView;
private TextView awaitingMessage; private TextView awaitingMessage;
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
private SurfaceHolder surfaceHolder; private SurfaceTexture surfaceTexture;
private int videoWidth; private int videoWidth;
private int videoHeight; private int videoHeight;
private boolean streamRendering; private boolean streamRendering;
@@ -78,6 +80,11 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
runOnUiThread(() -> { runOnUiThread(() -> {
streamRendering = true; streamRendering = true;
hideAwaitingOverlay(); hideAwaitingOverlay();
ZoomPanContainer zoom = findViewById(R.id.zoom_container);
if (zoom != null) {
zoom.resetTransform();
}
applyTextureFitCenter();
updateDiagnosticsVisibility(); updateDiagnosticsVisibility();
}); });
} }
@@ -138,15 +145,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container); ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container);
aspectContainer = findViewById(R.id.aspect_container); aspectContainer = findViewById(R.id.aspect_container);
surfaceView = findViewById(R.id.surface_playback); textureView = findViewById(R.id.surface_playback);
zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent); zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent);
awaitingOverlay = findViewById(R.id.overlay_awaiting); awaitingOverlay = findViewById(R.id.overlay_awaiting);
diagnosticsText = findViewById(R.id.text_diagnostics); diagnosticsText = findViewById(R.id.text_diagnostics);
robotView = findViewById(R.id.image_robot); robotView = findViewById(R.id.image_robot);
awaitingMessage = findViewById(R.id.text_awaiting_message); awaitingMessage = findViewById(R.id.text_awaiting_message);
surfaceHolder = surfaceView.getHolder(); textureView.setSurfaceTextureListener(surfaceListener);
surfaceHolder.addCallback(this);
surfaceView.setZOrderMediaOverlay(false);
applyAspectRatio(); applyAspectRatio();
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) { if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
@@ -319,12 +324,24 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
videoWidth = width; videoWidth = width;
videoHeight = height; videoHeight = height;
applyAspectRatio(); applyAspectRatio();
if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) { applyTextureFitCenter();
surfaceHolder.setFixedSize(videoWidth, videoHeight); if (surfaceTexture != null) {
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);
attachSurfaceIfReady(); attachSurfaceIfReady();
} }
} }
private void applyTextureFitCenter() {
if (textureView == null || videoWidth <= 0 || videoHeight <= 0) {
return;
}
if (textureView.getWidth() == 0 || textureView.getHeight() == 0) {
textureView.post(this::applyTextureFitCenter);
return;
}
PlaybackTextureTransform.applyFitCenter(textureView, videoWidth, videoHeight);
}
private void applyFullscreen() { private void applyFullscreen() {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().getDecorView().setSystemUiVisibility( getWindow().getDecorView().setSystemUiVisibility(
@@ -340,42 +357,49 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
} }
private void applyAspectRatio() { private void applyAspectRatio() {
if (videoWidth > 0 && videoHeight > 0) { // Full-screen TextureView; letterboxing via transform only (avoids double-scaling).
aspectContainer.setAspectRatio(videoWidth, videoHeight); aspectContainer.setAspectRatio(0, 0);
}
} }
@Override private final TextureView.SurfaceTextureListener surfaceListener =
public void surfaceCreated(SurfaceHolder holder) { new TextureView.SurfaceTextureListener() {
if (videoWidth > 0 && videoHeight > 0) { @Override
holder.setFixedSize(videoWidth, videoHeight); public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) {
} surfaceTexture = texture;
surfaceSentToService = false; if (videoWidth > 0 && videoHeight > 0) {
attachSurfaceIfReady(); texture.setDefaultBufferSize(videoWidth, videoHeight);
} }
surfaceSentToService = false;
attachSurfaceIfReady();
}
@Override @Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) {
if (videoWidth > 0 && videoHeight > 0) { if (videoWidth > 0 && videoHeight > 0) {
holder.setFixedSize(videoWidth, videoHeight); texture.setDefaultBufferSize(videoWidth, videoHeight);
} }
} applyTextureFitCenter();
}
@Override @Override
public void surfaceDestroyed(SurfaceHolder holder) { public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
surfaceSentToService = false; surfaceSentToService = false;
detachSurface(); detachSurface();
} surfaceTexture = null;
return true;
}
@Override
public void onSurfaceTextureUpdated(SurfaceTexture texture) {
}
};
private void attachSurfaceIfReady() { private void attachSurfaceIfReady() {
if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) { if (surfaceSentToService || !bound || receiverService == null || surfaceTexture == null) {
return;
}
if (!surfaceHolder.getSurface().isValid()) {
return; return;
} }
try { try {
receiverService.attachVideoSurface(surfaceHolder.getSurface()); receiverService.attachVideoSurface(new Surface(surfaceTexture));
surfaceSentToService = true; surfaceSentToService = true;
} catch (RemoteException ignored) { } catch (RemoteException ignored) {
} }

View File

@@ -11,8 +11,10 @@ import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.CastFramingContext; import com.foxx.androidcast.network.CastFramingContext;
import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession; import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastSessionGate;
import com.foxx.androidcast.network.CastTransport; import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
@@ -41,6 +43,9 @@ public class ReceiverSession {
void onDisconnected(); void onDisconnected();
/** Sender sent {@link CastProtocol#MSG_GOODBYE} — stop audio/video playback immediately. */
void onCastEnded();
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */ /** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
void onListening(); void onListening();
@@ -58,6 +63,7 @@ public class ReceiverSession {
private CastSession session; private CastSession session;
private CastTransport listenTransport; private CastTransport listenTransport;
private final CastSessionGate inboundSessionGate = new CastSessionGate();
private NetworkFeedbackManager networkFeedback; private NetworkFeedbackManager networkFeedback;
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher; private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
@@ -94,6 +100,14 @@ public class ReceiverSession {
listenTransport.close(); listenTransport.close();
listenTransport = null; listenTransport = null;
} }
inboundSessionGate.reset();
}
public TransportLossStats getTransportLossStats() {
if (listenTransport != null) {
return listenTransport.getLossStats();
}
return new TransportLossStats();
} }
public void sendAudioConsent(boolean accept) { public void sendAudioConsent(boolean accept) {
@@ -111,17 +125,21 @@ public class ReceiverSession {
while (running.get()) { while (running.get()) {
try { try {
if (listenTransport == null) { if (listenTransport == null) {
com.foxx.androidcast.network.CastTransportFactory.init(appContext);
listenTransport = CastTransportFactory.create(localSettings); listenTransport = CastTransportFactory.create(localSettings);
inboundSessionGate.reset();
listenTransport.setInboundSessionGate(inboundSessionGate);
listenTransport.setFramingContext(new CastFramingContext( listenTransport.setFramingContext(new CastFramingContext(
0, 0,
DeviceInfo.getPlaceholderId(appContext), DeviceInfo.getPlaceholderId(appContext),
CastCodecFlags.localDecoderVideoMask(), CastCodecFlags.localDecoderVideoMask(),
CastCodecFlags.localAudioMask())); CastCodecFlags.localAudioMask()));
session = new CastSession(listenTransport); session = new CastSession(listenTransport);
session.prepareListen(CastConfig.CAST_PORT); int listenPort = CastConfig.portForTransport(localSettings.getTransport());
session.prepareListen(listenPort);
listener.onListening(); listener.onListening();
postStatus("Listening (" + listenTransport.getModeLabel() + ") port " postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
+ CastConfig.CAST_PORT); + listenPort);
} else if (session == null) { } else if (session == null) {
session = new CastSession(listenTransport); session = new CastSession(listenTransport);
} }
@@ -158,7 +176,13 @@ public class ReceiverSession {
} finally { } finally {
if (session != null) { if (session != null) {
session.releaseConnection(); session.releaseConnection();
session = null;
} }
if (listenTransport != null) {
listenTransport.close();
listenTransport = null;
}
inboundSessionGate.reset();
networkFeedback = null; networkFeedback = null;
listener.onDisconnected(); listener.onDisconnected();
} }
@@ -214,6 +238,7 @@ public class ReceiverSession {
break; break;
case CastProtocol.MSG_GOODBYE: case CastProtocol.MSG_GOODBYE:
postStatus("Sender stopped casting"); postStatus("Sender stopped casting");
listener.onCastEnded();
return; return;
default: default:
break; break;

View File

@@ -22,6 +22,9 @@ public class VideoDecoder {
/** Called for each frame after the first. */ /** Called for each frame after the first. */
default void onFrameRendered() {} default void onFrameRendered() {}
/** Decoder output size (may differ from stream config crop). */
default void onOutputSizeChanged(int width, int height) {}
} }
private final Object lock = new Object(); private final Object lock = new Object();
@@ -66,7 +69,7 @@ public class VideoDecoder {
if (released || data == null || data.length == 0) { if (released || data == null || data.length == 0) {
return; return;
} }
byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data; byte[] annexB = needsAnnexB(mime) ? H264Bitstream.toAnnexB(data) : data;
if (!configured || codec == null) { if (!configured || codec == null) {
pending.add(new PendingFrame(ptsUs, keyFrame, annexB)); pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
return; return;
@@ -143,7 +146,9 @@ public class VideoDecoder {
break; break;
} }
if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
Log.i(TAG, "Output format: " + c.getOutputFormat()); MediaFormat fmt = c.getOutputFormat();
Log.i(TAG, "Output format: " + fmt);
notifyOutputSizeLocked(fmt);
continue; continue;
} }
if (outIndex < 0) { if (outIndex < 0) {
@@ -161,6 +166,23 @@ public class VideoDecoder {
} }
} }
private void notifyOutputSizeLocked(MediaFormat format) {
if (listener == null || format == null) {
return;
}
int w = format.getInteger(MediaFormat.KEY_WIDTH);
int h = format.getInteger(MediaFormat.KEY_HEIGHT);
if (format.containsKey("crop-right") && format.containsKey("crop-left")) {
w = format.getInteger("crop-right") + 1 - format.getInteger("crop-left");
}
if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) {
h = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top");
}
if (w > 0 && h > 0) {
listener.onOutputSizeChanged(w, h);
}
}
private void notifyFirstFrameLocked() { private void notifyFirstFrameLocked() {
if (listener == null) { if (listener == null) {
return; return;
@@ -175,6 +197,10 @@ public class VideoDecoder {
} }
} }
private static boolean needsAnnexB(String mime) {
return CodecNegotiator.isH264(mime) || CodecNegotiator.isHevc(mime);
}
private static final class PendingFrame { private static final class PendingFrame {
final long ptsUs; final long ptsUs;
final boolean keyFrame; final boolean keyFrame;

View File

@@ -50,6 +50,10 @@ public class AudioCapture {
Log.w(TAG, "Audio capture requires API 29+"); Log.w(TAG, "Audio capture requires API 29+");
return; return;
} }
if (projection == null) {
startMicOnly(callback);
return;
}
if (!startPlayback(projection)) { if (!startPlayback(projection)) {
Log.e(TAG, "Playback capture unavailable"); Log.e(TAG, "Playback capture unavailable");
return; return;
@@ -72,6 +76,59 @@ public class AudioCapture {
thread.start(); thread.start();
} }
private void startMicOnly(Callback callback) {
micRecord = canUseMic() ? buildMicRecord() : null;
if (micRecord == null || micRecord.getState() != AudioRecord.STATE_INITIALIZED) {
micRecord = null;
Log.w(TAG, "Mic-only capture unavailable");
return;
}
running = true;
micRecord.startRecording();
captureMode = "mic";
if (callback != null) {
callback.onCaptureModeChanged(captureMode);
}
Log.i(TAG, "Mic-only capture for calibration VU");
thread = new Thread(() -> micOnlyLoop(callback), "AudioCaptureMic");
thread.start();
}
private void micOnlyLoop(Callback callback) {
int frameBytes = pcmFrameBytes();
byte[] micBuf = new byte[frameBytes];
byte[] copy = new byte[frameBytes];
long frameIndex = 0;
while (running) {
if (!readFully(micRecord, micBuf, frameBytes)) {
continue;
}
System.arraycopy(micBuf, 0, copy, 0, frameBytes);
long ptsUs = frameIndex * 20_000L;
frameIndex++;
if (callback != null) {
callback.onPcm(copy, frameBytes, ptsUs,
com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
}
}
}
private static int pcmFrameBytes() {
return CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50;
}
private static boolean readFully(AudioRecord record, byte[] buf, int length) {
int off = 0;
while (off < length && record != null) {
int n = record.read(buf, off, length - off);
if (n <= 0) {
return false;
}
off += n;
}
return true;
}
private boolean startPlayback(MediaProjection projection) { private boolean startPlayback(MediaProjection projection) {
try { try {
playbackRecord = buildPlaybackRecord(projection); playbackRecord = buildPlaybackRecord(projection);
@@ -138,40 +195,32 @@ public class AudioCapture {
} }
private void mixLoop(Callback callback) { private void mixLoop(Callback callback) {
byte[] playBuf = new byte[4096]; int frameBytes = pcmFrameBytes();
byte[] micBuf = new byte[4096]; byte[] playBuf = new byte[frameBytes];
long startNs = System.nanoTime(); byte[] micBuf = new byte[frameBytes];
byte[] mixOut = new byte[frameBytes];
long frameIndex = 0;
while (running) { while (running) {
int pRead = playbackRecord.read(playBuf, 0, playBuf.length); if (!readFully(playbackRecord, playBuf, frameBytes)) {
int mRead = micRecord != null ? micRecord.read(micBuf, 0, micBuf.length) : 0;
if (pRead < 0 && mRead < 0) {
Log.w(TAG, "Audio read error");
continue; continue;
} }
byte[] out; int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0;
int outLen; System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
if (pRead > 0) { if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
out = playBuf; for (int i = 0; i < frameBytes; i++) {
outLen = pRead; mixOut[i] = (byte) Math.max(mixOut[i] & 0xff, micBuf[i] & 0xff);
String mode = hasAudibleSamples(playBuf, pRead) ? "playback" : "playback-quiet";
if (!mode.equals(captureMode)) {
captureMode = mode;
notifyMode(callback, captureMode);
} }
} else if (mRead > 0 && micRecord != null) {
out = micBuf;
outLen = mRead;
if (!"mic".equals(captureMode)) {
captureMode = "mic";
notifyMode(callback, captureMode);
}
} else {
continue;
} }
long ptsUs = (System.nanoTime() - startNs) / 1000; String mode = hasAudibleSamples(playBuf, frameBytes) ? "playback" : "playback-quiet";
if (!mode.equals(captureMode)) {
captureMode = mode;
notifyMode(callback, captureMode);
}
long ptsUs = frameIndex * 20_000L;
frameIndex++;
if (callback != null) { if (callback != null) {
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen); PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
callback.onPcm(out, outLen, ptsUs, levels); callback.onPcm(mixOut, frameBytes, ptsUs, levels);
} }
} }
} }

View File

@@ -85,6 +85,8 @@ public class AudioEncoder {
public void stop() { public void stop() {
running = false; running = false;
MediaCodec c = codec;
codec = null;
if (drainThread != null) { if (drainThread != null) {
try { try {
drainThread.join(1000); drainThread.join(1000);
@@ -92,13 +94,15 @@ public class AudioEncoder {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
} }
if (codec != null) { if (c != null) {
try { try {
codec.stop(); c.stop();
} catch (Exception ignored) {
}
try {
c.release();
} catch (Exception ignored) { } catch (Exception ignored) {
} }
codec.release();
codec = null;
} }
} }

View File

@@ -11,25 +11,32 @@ import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
/** Background TCP/UDP send queue so encoding is not blocked by network I/O. */ /** Background send queue: audio has its own lane so video backlog cannot starve it. */
public final class CastSendPump { public final class CastSendPump {
private static final String TAG = "CastSendPump"; private static final String TAG = "CastSendPump";
private static final int MAX_QUEUE = 48; private static final int MAX_VIDEO_QUEUE = 40;
private static final int MAX_AUDIO_QUEUE = 96;
private final LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE); private final LinkedBlockingQueue<Outgoing> videoQueue = new LinkedBlockingQueue<>(MAX_VIDEO_QUEUE);
private final LinkedBlockingQueue<Outgoing> audioQueue = new LinkedBlockingQueue<>(MAX_AUDIO_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false);
private Thread thread; private Thread thread;
private CastSession session; private CastSession session;
private Runnable onSendFailed; private Runnable onSendFailed;
private PassThroughNetworkControlPlane statsPlane; private PassThroughNetworkControlPlane statsPlane;
private volatile int droppedPFrames; private volatile int droppedPFrames;
private volatile int droppedAudioFrames;
public int getDroppedPFrames() { public int getDroppedPFrames() {
return droppedPFrames; return droppedPFrames;
} }
public int getDroppedAudioFrames() {
return droppedAudioFrames;
}
public int getQueueDepth() { public int getQueueDepth() {
return queue.size(); return videoQueue.size() + audioQueue.size();
} }
public void start(CastSession session, Runnable onSendFailed) { public void start(CastSession session, Runnable onSendFailed) {
@@ -44,21 +51,23 @@ public final class CastSendPump {
return; return;
} }
thread = new Thread(this::run, "CastSendPump"); thread = new Thread(this::run, "CastSendPump");
thread.setPriority(Thread.NORM_PRIORITY + 1);
thread.start(); thread.start();
} }
public void stop() { public void stop() {
running.set(false); running.set(false);
videoQueue.clear();
audioQueue.clear();
if (thread != null) { if (thread != null) {
thread.interrupt(); thread.interrupt();
try { try {
thread.join(1500); thread.join(500);
} catch (InterruptedException ignored) { } catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
thread = null; thread = null;
} }
queue.clear();
session = null; session = null;
} }
@@ -67,17 +76,12 @@ public final class CastSendPump {
return; return;
} }
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload); Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
if (!queue.offer(item)) { if (!videoQueue.offer(item)) {
if (!keyFrame) { if (!keyFrame) {
Outgoing dropped = queue.poll(); dropOneVideoPFrame();
if (dropped != null && !dropped.keyFrame) {
droppedPFrames++;
}
} }
if (!queue.offer(item)) { if (!videoQueue.offer(item) && !keyFrame) {
if (!keyFrame) { droppedPFrames++;
droppedPFrames++;
}
} }
} }
} }
@@ -87,15 +91,43 @@ public final class CastSendPump {
return; return;
} }
Outgoing item = Outgoing.raw(type, payload); Outgoing item = Outgoing.raw(type, payload);
while (running.get() && !queue.offer(item)) { boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG;
queue.poll(); if (audio) {
if (!audioQueue.offer(item)) {
Outgoing dropped = audioQueue.poll();
if (dropped != null) {
droppedAudioFrames++;
}
audioQueue.offer(item);
}
return;
}
while (running.get() && !videoQueue.offer(item)) {
dropOneVideoPFrame();
}
}
private void dropOneVideoPFrame() {
for (Outgoing o : videoQueue) {
if (o.type == CastProtocol.MSG_VIDEO_FRAME && !o.keyFrame) {
videoQueue.remove(o);
droppedPFrames++;
return;
}
}
Outgoing head = videoQueue.poll();
if (head != null && head.type == CastProtocol.MSG_VIDEO_FRAME && !head.keyFrame) {
droppedPFrames++;
} }
} }
private void run() { private void run() {
while (running.get()) { while (running.get()) {
try { try {
Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS); Outgoing item = audioQueue.poll();
if (item == null) {
item = videoQueue.poll(50, TimeUnit.MILLISECONDS);
}
if (item == null || session == null) { if (item == null || session == null) {
continue; continue;
} }

View File

@@ -149,18 +149,27 @@ public final class MultiCastCoordinator {
return any; return any;
} }
public void closeAll() { public void sendGoodbyeAll() {
for (ActivePeer p : peers) { for (ActivePeer p : peers) {
try { try {
p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]); p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
} catch (IOException ignored) { } catch (IOException ignored) {
} }
p.session.close(); }
}
public void closeAll() {
for (ActivePeer p : peers) {
try {
p.session.close();
} catch (Exception ignored) {
}
} }
peers.clear(); peers.clear();
} }
private CastSession createSession(CastSettings settings) throws IOException { private CastSession createSession(CastSettings settings) throws IOException {
CastTransportFactory.init(appContext);
CastSession session = new CastSession(CastTransportFactory.create(settings)); CastSession session = new CastSession(CastTransportFactory.create(settings));
session.getTransport().setFramingContext(framingContext); session.getTransport().setFramingContext(framingContext);
return session; return session;

View File

@@ -36,6 +36,10 @@ import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -46,7 +50,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */ /** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
public class ScreenCastService extends Service { public class ScreenCastService extends Service implements
com.foxx.androidcast.sender.calibration.CalibrationCastDriver.FallbackHost {
private static final String TAG = "ScreenCastService"; private static final String TAG = "ScreenCastService";
private static final int CONNECT_RETRIES = 20; private static final int CONNECT_RETRIES = 20;
private static final long CONNECT_RETRY_MS = 1000; private static final long CONNECT_RETRY_MS = 1000;
@@ -58,12 +63,14 @@ public class ScreenCastService extends Service {
public static final String EXTRA_PIN = "pin"; public static final String EXTRA_PIN = "pin";
public static final String EXTRA_DEVICE_NAME = "device_name"; public static final String EXTRA_DEVICE_NAME = "device_name";
public static final String EXTRA_TARGETS = "cast_targets"; public static final String EXTRA_TARGETS = "cast_targets";
public static final String EXTRA_CALIBRATION = "calibration_mode";
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND"; public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>(); private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final AtomicBoolean stopping = new AtomicBoolean(false); private final AtomicBoolean stopping = new AtomicBoolean(false);
private final AtomicBoolean casting = new AtomicBoolean(false); private final AtomicBoolean casting = new AtomicBoolean(false);
private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);
private MediaProjection projection; private MediaProjection projection;
private VirtualDisplay virtualDisplay; private VirtualDisplay virtualDisplay;
@@ -92,6 +99,11 @@ public class ScreenCastService extends Service {
private int captureDensity; private int captureDensity;
private DisplayManager displayManager; private DisplayManager displayManager;
private DisplayManager.DisplayListener displayListener; private DisplayManager.DisplayListener displayListener;
private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver;
private volatile boolean calibrationMode;
private volatile boolean cameraMode;
private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture;
private SessionStatsRecorder sessionStatsRecorder;
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() { private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
@Override @Override
@@ -128,7 +140,9 @@ public class ScreenCastService extends Service {
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
SessionStatsStore.pruneOnAppStart(this);
CastNotifications.createChannels(this); CastNotifications.createChannels(this);
com.foxx.androidcast.network.CastTransportFactory.init(this);
} }
@Override @Override
@@ -149,6 +163,8 @@ public class ScreenCastService extends Service {
} }
private void runCast(Intent intent) { private void runCast(Intent intent) {
resourcesReleased.set(false);
stopping.set(false);
if (intent == null) { if (intent == null) {
failAndStop(getString(R.string.error_no_intent)); failAndStop(getString(R.string.error_no_intent));
return; return;
@@ -161,7 +177,11 @@ public class ScreenCastService extends Service {
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME); String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
CastSettings settings = IntentExtras.getCastSettings(intent); CastSettings settings = IntentExtras.getCastSettings(intent);
if (data == null) { boolean calibration = settings != null
&& settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
boolean camera = settings != null
&& settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
if (!calibration && !camera && data == null) {
failAndStop(getString(R.string.error_projection_data)); failAndStop(getString(R.string.error_projection_data));
return; return;
} }
@@ -169,6 +189,16 @@ public class ScreenCastService extends Service {
failAndStop(getString(R.string.error_missing_params)); failAndStop(getString(R.string.error_missing_params));
return; return;
} }
cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
failAndStop(getString(R.string.capture_camera_not_implemented));
return;
}
int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
if (port <= 0 || port == com.foxx.androidcast.CastConfig.CAST_PORT) {
port = castPort;
}
acquireWakeLock(); acquireWakeLock();
try { try {
@@ -210,7 +240,17 @@ public class ScreenCastService extends Service {
casting.set(true); casting.set(true);
CastActiveState.setSenderCasting(true); CastActiveState.setSenderCasting(true);
activeSettings = settings; activeSettings = settings;
startCaptureOnMainThread(resultCode, new Intent(data), settings); sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
sessionStatsRecorder.mergeSettings(settings);
sessionStatsRecorder.setClientCount(peers.size());
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
if (calibration) {
startCalibrationCaptureOnMainThread(settings);
} else if (camera) {
startCameraCaptureOnMainThread(settings);
} else {
startCaptureOnMainThread(resultCode, new Intent(data), settings);
}
scheduleTuningTick(); scheduleTuningTick();
while (!stopping.get()) { while (!stopping.get()) {
try { try {
@@ -285,6 +325,83 @@ public class ScreenCastService extends Service {
throw new IOException(getString(R.string.error_connect_failed, host, port), last); throw new IOException(getString(R.string.error_connect_failed, host, port), last);
} }
private void startCalibrationCaptureOnMainThread(CastSettings settings) throws IOException {
if (Looper.myLooper() != mainHandler.getLooper()) {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<IOException> error = new AtomicReference<>();
mainHandler.post(() -> {
try {
startCalibrationCaptureOnMainThread(settings);
} catch (Exception e) {
error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e));
} finally {
latch.countDown();
}
});
try {
if (!latch.await(15, TimeUnit.SECONDS)) {
throw new IOException("Calibration setup timeout");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted", e);
}
if (error.get() != null) {
throw error.get();
}
return;
}
CastResolution.Size size = CastResolution.calibrationTestSize();
setupVideoPipeline(size, settings, true);
startAudioPipeline(settings, true);
updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight));
}
private void startCameraCaptureOnMainThread(CastSettings settings) throws IOException {
if (Looper.myLooper() != mainHandler.getLooper()) {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<IOException> error = new AtomicReference<>();
mainHandler.post(() -> {
try {
startCameraCaptureOnMainThread(settings);
} catch (Exception e) {
error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e));
} finally {
latch.countDown();
}
});
try {
if (!latch.await(20, TimeUnit.SECONDS)) {
throw new IOException("Camera setup timeout");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted", e);
}
if (error.get() != null) {
throw error.get();
}
return;
}
DisplayMetrics metrics = new DisplayMetrics();
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics);
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
setupVideoPipeline(size, settings, false);
Surface surface = videoEncoder != null ? videoEncoder.getInputSurface() : null;
if (surface == null) {
throw new IOException("Encoder surface unavailable for camera");
}
cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this);
try {
cameraCapture.start(surface, captureWidth, captureHeight);
} catch (android.hardware.camera2.CameraAccessException e) {
throw new IOException("Camera open failed: " + e.getMessage(), e);
}
startAudioPipeline(settings, true);
updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight));
}
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException { private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
if (Looper.myLooper() != mainHandler.getLooper()) { if (Looper.myLooper() != mainHandler.getLooper()) {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
@@ -335,16 +452,17 @@ public class ScreenCastService extends Service {
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics); wm.getDefaultDisplay().getRealMetrics(metrics);
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats()); CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
setupVideoPipeline(size, settings); setupVideoPipeline(size, settings, false);
registerDisplayListener(); registerDisplayListener();
startAudioPipeline(settings); startAudioPipeline(settings, false);
String mode = session.getTransport().getModeLabel(); String mode = session.getTransport().getModeLabel();
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight)); updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
} }
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException { private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
throws IOException {
if (settings.isPassthrough()) { if (settings.isPassthrough()) {
throw new IOException("Passthrough video is not implemented yet (debug only)"); throw new IOException("Passthrough video is not implemented yet (debug only)");
} }
@@ -376,45 +494,29 @@ public class ScreenCastService extends Service {
} }
videoEncoder = new VideoEncoder(); videoEncoder = new VideoEncoder();
VideoEncoder.Callback videoCb = new VideoEncoder.Callback() { VideoEncoder.Callback videoCb = buildVideoEncoderCallback(mime);
@Override
public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
if (stopping.get()) {
return;
}
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
}
@Override if (calibration) {
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { startCalibrationVideoEncoder(mime, effectiveParams, videoCb, true);
if ((sendPump == null && fanoutPump == null) || frameData == null) { return;
return; }
}
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
&& !keyFrame
&& frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
if (tiny) {
return;
}
lastSignificantFrameMs = System.currentTimeMillis();
senderMetrics.onEncodedFrame(keyFrame);
enqueueVideoFrame(ptsUs, keyFrame, frameData);
}
};
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb); Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
encoderKeepalive = new EncoderKeepalive(); encoderKeepalive = new EncoderKeepalive();
encoderKeepalive.start(videoEncoder); encoderKeepalive.start(videoEncoder);
virtualDisplay = projection.createVirtualDisplay( if (projection != null) {
"AndroidCast", virtualDisplay = projection.createVirtualDisplay(
captureWidth, "AndroidCast",
captureHeight, captureWidth,
captureDensity, captureHeight,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, captureDensity,
surface, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
null, surface,
null); null,
null);
} else {
throw new IOException("MediaProjection required for screen capture");
}
videoEncoder.requestKeyframe(); videoEncoder.requestKeyframe();
Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight); Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight);
} }
@@ -450,7 +552,8 @@ public class ScreenCastService extends Service {
} }
private void maybeReconfigureForRotation() { private void maybeReconfigureForRotation() {
if (!casting.get() || projection == null || activeSettings == null || stopping.get()) { if (calibrationMode || cameraMode || !casting.get() || projection == null
|| activeSettings == null || stopping.get()) {
return; return;
} }
DisplayMetrics metrics = new DisplayMetrics(); DisplayMetrics metrics = new DisplayMetrics();
@@ -461,7 +564,7 @@ public class ScreenCastService extends Service {
return; return;
} }
try { try {
setupVideoPipeline(size, activeSettings); setupVideoPipeline(size, activeSettings, false);
updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight)); updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight));
} catch (IOException e) { } catch (IOException e) {
Log.e(TAG, "Rotation reconfigure failed", e); Log.e(TAG, "Rotation reconfigure failed", e);
@@ -484,6 +587,11 @@ public class ScreenCastService extends Service {
s.encodeQueueDepth = sendPump.getQueueDepth(); s.encodeQueueDepth = sendPump.getQueueDepth();
s.droppedPFrames = sendPump.getDroppedPFrames(); s.droppedPFrames = sendPump.getDroppedPFrames();
} }
TransportLossStats snap = buildSenderLossSnapshot();
TransportLossStatsBridge.applyTo(s, snap);
if (sessionStatsRecorder != null) {
sessionStatsRecorder.sampleNetwork(s, getPeerStats());
}
s.audioActive = audioEncoder != null && audioFramesSent > 0; s.audioActive = audioEncoder != null && audioFramesSent > 0;
s.audioFramesSent = audioFramesSent; s.audioFramesSent = audioFramesSent;
s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent(); s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent();
@@ -543,7 +651,7 @@ public class ScreenCastService extends Service {
} }
private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) { private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) {
if (activeSettings == null if (calibrationMode || cameraMode || activeSettings == null
|| activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) { || activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) {
return; return;
} }
@@ -557,19 +665,19 @@ public class ScreenCastService extends Service {
return; return;
} }
try { try {
setupVideoPipeline(size, activeSettings); setupVideoPipeline(size, activeSettings, false);
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight); Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
} catch (IOException e) { } catch (IOException e) {
Log.w(TAG, "Adaptive resize failed", e); Log.w(TAG, "Adaptive resize failed", e);
} }
} }
private void startAudioPipeline(CastSettings settings) { private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) {
if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.i(TAG, "Audio capture disabled for this session"); Log.i(TAG, "Audio capture disabled for this session");
return; return;
} }
if (projection == null) { if (projection == null && !calibrationOnlyMic) {
Log.w(TAG, "Audio skipped — no MediaProjection"); Log.w(TAG, "Audio skipped — no MediaProjection");
return; return;
} }
@@ -609,11 +717,15 @@ public class ScreenCastService extends Service {
senderAudioMetrics.reset(); senderAudioMetrics.reset();
audioCaptureMode = "playback"; audioCaptureMode = "playback";
audioCapture = new AudioCapture(this); audioCapture = new AudioCapture(this);
audioCapture.start(projection, new AudioCapture.Callback() { MediaProjection audioProjection = calibrationOnlyMic ? null : projection;
audioCapture.start(audioProjection, new AudioCapture.Callback() {
@Override @Override
public void onPcm(byte[] pcm, int length, long ptsUs, public void onPcm(byte[] pcm, int length, long ptsUs,
com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) { com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
senderAudioMetrics.onPcm(levels); senderAudioMetrics.onPcm(levels);
if (calibrationDriver != null) {
calibrationDriver.setVuFromPcm(pcm, length);
}
if (audioEncoder != null) { if (audioEncoder != null) {
audioEncoder.queuePcm(pcm, length, ptsUs); audioEncoder.queuePcm(pcm, length, ptsUs);
} }
@@ -634,7 +746,8 @@ public class ScreenCastService extends Service {
private void handleAudioConsent(boolean accept) { private void handleAudioConsent(boolean accept) {
if (accept) { if (accept) {
if (audioEncoder == null && activeSettings != null) { if (audioEncoder == null && activeSettings != null) {
startAudioPipeline(activeSettings); startAudioPipeline(activeSettings, activeSettings != null
&& activeSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST);
} }
Log.i(TAG, "Receiver accepted audio"); Log.i(TAG, "Receiver accepted audio");
} else { } else {
@@ -695,6 +808,10 @@ public class ScreenCastService extends Service {
} }
private void releaseAll() { private void releaseAll() {
if (!resourcesReleased.compareAndSet(false, true)) {
return;
}
finishSessionStatsAsync();
stopping.set(true); stopping.set(true);
casting.set(false); casting.set(false);
CastActiveState.setSenderCasting(false); CastActiveState.setSenderCasting(false);
@@ -708,10 +825,16 @@ public class ScreenCastService extends Service {
encoderKeepalive.stop(); encoderKeepalive.stop();
encoderKeepalive = null; encoderKeepalive = null;
} }
if (videoEncoder != null) { if (cameraCapture != null) {
videoEncoder.stop(); cameraCapture.stop();
videoEncoder = null; cameraCapture = null;
} }
if (calibrationDriver != null) {
calibrationDriver.release();
calibrationDriver = null;
}
calibrationMode = false;
cameraMode = false;
if (audioCapture != null) { if (audioCapture != null) {
audioCapture.stop(); audioCapture.stop();
audioCapture = null; audioCapture = null;
@@ -720,28 +843,36 @@ public class ScreenCastService extends Service {
audioEncoder.stop(); audioEncoder.stop();
audioEncoder = null; audioEncoder = null;
} }
if (videoEncoder != null) {
videoEncoder.stop();
videoEncoder = null;
}
if (projection != null) { if (projection != null) {
projection.stop(); projection.stop();
projection = null; projection = null;
} }
if (multiCoordinator != null) { if (multiCoordinator != null) {
multiCoordinator.closeAll(); multiCoordinator.sendGoodbyeAll();
multiCoordinator = null;
} else if (session != null) { } else if (session != null) {
try { try {
session.send(CastProtocol.MSG_GOODBYE, new byte[0]); session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
session.close(); }
session = null; if (sendPump != null) {
sendPump.stop();
sendPump = null;
} }
if (fanoutPump != null) { if (fanoutPump != null) {
fanoutPump.stop(); fanoutPump.stop();
fanoutPump = null; fanoutPump = null;
} }
if (sendPump != null) { if (multiCoordinator != null) {
sendPump.stop(); multiCoordinator.closeAll();
sendPump = null; multiCoordinator = null;
} else if (session != null) {
session.close();
session = null;
} }
releaseWakeLock(); releaseWakeLock();
} }
@@ -752,10 +883,17 @@ public class ScreenCastService extends Service {
} }
private void stopSelfSafely() { private void stopSelfSafely() {
stopping.set(true); if (!stopping.compareAndSet(false, true)) {
releaseAll(); return;
stopForeground(STOP_FOREGROUND_REMOVE); }
stopSelf(); Thread stopper = new Thread(() -> {
releaseAll();
mainHandler.post(() -> {
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
});
}, "CastStop");
stopper.start();
} }
private void startSenderForeground(String text, boolean withAudio) { private void startSenderForeground(String text, boolean withAudio) {
@@ -771,6 +909,41 @@ public class ScreenCastService extends Service {
} }
} }
private TransportLossStats buildSenderLossSnapshot() {
TransportLossStats snap = session != null
? session.getTransport().getLossStats() : new TransportLossStats();
if (sendPump != null) {
snap.sendDroppedVideoP = sendPump.getDroppedPFrames();
snap.sendDroppedAudio = sendPump.getDroppedAudioFrames();
snap.sendQueueDepth = sendPump.getQueueDepth();
} else if (fanoutPump != null) {
snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
snap.sendQueueDepth = fanoutPump.getQueueDepth();
}
return snap;
}
private void finishSessionStatsAsync() {
final SessionStatsRecorder recorder = sessionStatsRecorder;
sessionStatsRecorder = null;
if (recorder == null) {
return;
}
if (activeSettings != null) {
recorder.setResolution(captureWidth, captureHeight);
recorder.setCaptureMode(activeSettings.getCaptureMode() != null
? activeSettings.getCaptureMode().name() : "");
}
recorder.mergeLoss(buildSenderLossSnapshot());
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
}
private void logEncodedCodecSample(String mime, byte[] frameData) {
String probe = com.foxx.androidcast.media.EncodedStreamProbe.describeKeyframe(mime, frameData);
Log.i(TAG, "Encoded stream: " + com.foxx.androidcast.media.CodecNegotiator.displayName(mime)
+ "" + probe);
}
private void updateStatus(String text) { private void updateStatus(String text) {
status = text; status = text;
broadcastStatus(text); broadcastStatus(text);
@@ -790,6 +963,90 @@ public class ScreenCastService extends Service {
callbacks.finishBroadcast(); callbacks.finishBroadcast();
} }
private VideoEncoder.Callback buildVideoEncoderCallback(String mime) {
return new VideoEncoder.Callback() {
@Override
public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
if (stopping.get()) {
return;
}
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
}
@Override
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
if ((sendPump == null && fanoutPump == null) || frameData == null) {
return;
}
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
&& !keyFrame
&& frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
if (tiny) {
return;
}
lastSignificantFrameMs = System.currentTimeMillis();
senderMetrics.onEncodedFrame(keyFrame);
if (keyFrame) {
logEncodedCodecSample(mime, frameData);
}
enqueueVideoFrame(ptsUs, keyFrame, frameData);
}
};
}
private void startCalibrationVideoEncoder(String mime, CastTuningEngine.EffectiveParams params,
VideoEncoder.Callback videoCb, boolean tryGles) throws IOException {
if (encoderKeepalive != null) {
encoderKeepalive.stop();
encoderKeepalive = null;
}
if (calibrationDriver != null) {
calibrationDriver.release();
calibrationDriver = null;
}
if (videoEncoder != null) {
videoEncoder.stop();
}
videoEncoder = new VideoEncoder();
float density = getResources().getDisplayMetrics().density;
calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
if (tryGles) {
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb);
encoderKeepalive = new EncoderKeepalive();
encoderKeepalive.start(videoEncoder);
calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density);
videoEncoder.requestKeyframe();
Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime);
} else {
videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb);
encoderKeepalive = new EncoderKeepalive();
encoderKeepalive.start(videoEncoder);
calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density);
videoEncoder.requestKeyframe();
Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime);
}
}
@Override
public void onCalibrationNeedBufferEncoder() {
if (!calibrationMode || stopping.get() || activeSettings == null) {
return;
}
String mime = activeSettings.getNegotiatedVideoMime();
if (mime == null || mime.isEmpty()) {
return;
}
mainHandler.post(() -> {
try {
startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
} catch (IOException e) {
Log.e(TAG, "Calibration buffer fallback failed", e);
failAndStop("Calibration video failed");
}
});
}
@Override @Override
public IBinder onBind(Intent intent) { public IBinder onBind(Intent intent) {
return binder; return binder;

View File

@@ -14,6 +14,7 @@ import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View; import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ListView; import android.widget.ListView;
import android.widget.ProgressBar; import android.widget.ProgressBar;
@@ -35,7 +36,6 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
public class SenderActivity extends AppCompatActivity { public class SenderActivity extends AppCompatActivity {
private static final int REQUEST_MEDIA_PROJECTION = 2001; private static final int REQUEST_MEDIA_PROJECTION = 2001;
private static final long DISCOVERY_INTERVAL_MS = 30_000;
private static final long BACK_STOP_INTERVAL_MS = 2_000; private static final long BACK_STOP_INTERVAL_MS = 2_000;
private static final int DEV_TAP_UNLOCK = 7; private static final int DEV_TAP_UNLOCK = 7;
@@ -47,7 +47,28 @@ public class SenderActivity extends AppCompatActivity {
private TextView statusText; private TextView statusText;
private TextView titleText; private TextView titleText;
private ProgressBar discoveryProgress; private ProgressBar discoveryProgress;
private Button refreshButton;
private View advancedPanel; private View advancedPanel;
private enum DiscoveryPhase {
IDLE, SCANNING, PAUSED
}
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
private int discoverySecondsLeft;
private final Runnable autoScanRunnable = () -> beginScanPass(false);
private final Runnable discoveryCountdownTick = new Runnable() {
@Override
public void run() {
if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
if (discoverySecondsLeft > 0) {
discoverySecondsLeft--;
}
updateRefreshButtonLabel();
handler.postDelayed(this, 1000);
}
}
};
private EditText pinInput; private EditText pinInput;
private EditText manualHostInput; private EditText manualHostInput;
private EditText manualPortInput; private EditText manualPortInput;
@@ -57,15 +78,6 @@ public class SenderActivity extends AppCompatActivity {
private boolean useManualConnect; private boolean useManualConnect;
private long lastBackPressMs; private long lastBackPressMs;
private final Runnable discoveryWatchdog = new Runnable() {
@Override
public void run() {
if (discovery != null) {
restartDiscoveryInternal(false);
}
handler.postDelayed(this, DISCOVERY_INTERVAL_MS);
}
};
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -79,6 +91,7 @@ public class SenderActivity extends AppCompatActivity {
pinInput.setText(CastConfig.DEFAULT_PIN); pinInput.setText(CastConfig.DEFAULT_PIN);
statusText = findViewById(R.id.text_status); statusText = findViewById(R.id.text_status);
discoveryProgress = findViewById(R.id.progress_discovery); discoveryProgress = findViewById(R.id.progress_discovery);
refreshButton = findViewById(R.id.btn_refresh);
advancedPanel = findViewById(R.id.panel_advanced); advancedPanel = findViewById(R.id.panel_advanced);
manualHostInput = findViewById(R.id.edit_manual_host); manualHostInput = findViewById(R.id.edit_manual_host);
manualPortInput = findViewById(R.id.edit_manual_port); manualPortInput = findViewById(R.id.edit_manual_port);
@@ -143,12 +156,22 @@ public class SenderActivity extends AppCompatActivity {
@Override @Override
public void onScanStarted() { public void onScanStarted() {
discoveryPhase = DiscoveryPhase.SCANNING;
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
updateRefreshButtonLabel();
handler.removeCallbacks(discoveryCountdownTick);
handler.post(discoveryCountdownTick);
showDiscoveryProgress(); showDiscoveryProgress();
} }
@Override @Override
public void onScanEnded() { public void onScanEnded() {
hideDiscoveryProgress(); hideDiscoveryProgress();
discoveryPhase = DiscoveryPhase.PAUSED;
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000);
updateRefreshButtonLabel();
handler.removeCallbacks(autoScanRunnable);
handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS);
} }
}); });
@@ -170,7 +193,7 @@ public class SenderActivity extends AppCompatActivity {
} }
}); });
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery()); refreshButton.setOnClickListener(v -> onRefreshClicked());
findViewById(R.id.btn_start_cast).setOnClickListener(v -> findViewById(R.id.btn_start_cast).setOnClickListener(v ->
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow)); PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
findViewById(R.id.btn_manual_cast).setOnClickListener(v -> { findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
@@ -188,36 +211,60 @@ public class SenderActivity extends AppCompatActivity {
@Override @Override
protected void onStart() { protected void onStart() {
super.onStart(); super.onStart();
restartDiscovery(); beginScanPass(false);
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
} }
@Override @Override
protected void onStop() { protected void onStop() {
handler.removeCallbacks(discoveryWatchdog); handler.removeCallbacks(discoveryCountdownTick);
handler.removeCallbacks(autoScanRunnable);
if (discovery != null) { if (discovery != null) {
discovery.stop(); discovery.stop();
} }
discoveryPhase = DiscoveryPhase.IDLE;
updateRefreshButtonLabel();
hideDiscoveryProgress(); hideDiscoveryProgress();
super.onStop(); super.onStop();
} }
private void restartDiscovery() { private void onRefreshClicked() {
handler.removeCallbacks(discoveryWatchdog); if (discoveryPhase == DiscoveryPhase.SCANNING) {
restartDiscoveryInternal(true); return;
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS); }
handler.removeCallbacks(autoScanRunnable);
beginScanPass(true);
} }
private void restartDiscoveryInternal(boolean userRefresh) { private void beginScanPass(boolean userInitiated) {
deviceAdapter.clearDevices(); if (discovery != null && discovery.isBrowsing()) {
statusText.setText(userRefresh ? R.string.rescanning : R.string.searching); return;
}
if (userInitiated) {
deviceAdapter.clearDevices();
statusText.setText(R.string.rescanning);
} else if (deviceAdapter.getCount() == 0) {
statusText.setText(R.string.searching);
}
if (discovery != null) { if (discovery != null) {
discovery.stop(); discovery.stop();
discovery.clearDevices(); if (userInitiated) {
discovery.clearDevices();
}
} }
discovery.startBrowsing(); discovery.startBrowsing();
} }
private void updateRefreshButtonLabel() {
if (refreshButton == null) {
return;
}
if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft)));
} else {
refreshButton.setText(R.string.refresh);
}
}
private void showDiscoveryProgress() { private void showDiscoveryProgress() {
discoveryProgress.setVisibility(View.VISIBLE); discoveryProgress.setVisibility(View.VISIBLE);
discoveryProgress.bringToFront(); discoveryProgress.bringToFront();
@@ -303,6 +350,21 @@ public class SenderActivity extends AppCompatActivity {
} }
pendingPin = pin; pendingPin = pin;
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
launchCastService(null, Activity.RESULT_OK);
return;
}
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) {
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
androidx.core.app.ActivityCompat.requestPermissions(this,
new String[] {android.Manifest.permission.CAMERA}, PermissionHelper.REQUEST_CODE);
pendingCameraLaunch = true;
return;
}
launchCastService(null, Activity.RESULT_OK);
return;
}
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION); startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
} }
@@ -316,39 +378,61 @@ public class SenderActivity extends AppCompatActivity {
return mgr.createScreenCaptureIntent(); return mgr.createScreenCaptureIntent();
} }
private boolean pendingCameraLaunch;
private String pendingPin; private String pendingPin;
private String pendingHost; private String pendingHost;
private int pendingPort; private int pendingPort;
private String pendingDeviceName; private String pendingDeviceName;
private java.util.ArrayList<CastReceiverTarget> pendingTargets; private java.util.ArrayList<CastReceiverTarget> pendingTargets;
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionHelper.REQUEST_CODE && pendingCameraLaunch) {
pendingCameraLaunch = false;
if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
launchCastService(null, Activity.RESULT_OK);
} else {
Toast.makeText(this, R.string.capture_camera_not_implemented, Toast.LENGTH_LONG).show();
}
}
}
@Override @Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data); super.onActivityResult(requestCode, resultCode, data);
if (requestCode != REQUEST_MEDIA_PROJECTION) { if (requestCode != REQUEST_MEDIA_PROJECTION) {
return; return;
} }
if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) { if (resultCode != Activity.RESULT_OK || pendingHost == null) {
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
return; return;
} }
launchCastService(data, resultCode);
}
private void launchCastService(Intent projectionData, int resultCode) {
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME); String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
if (deviceName == null) { if (deviceName == null) {
deviceName = Build.MODEL; deviceName = Build.MODEL;
} }
Intent serviceIntent = new Intent(this, ScreenCastService.class); Intent serviceIntent = new Intent(this, ScreenCastService.class);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data)); if (projectionData != null) {
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(projectionData));
}
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost); serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort); serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin); serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName); serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
serviceIntent.putExtra(CastSettings.EXTRA, castSettings); serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true);
}
if (pendingTargets != null && !pendingTargets.isEmpty()) { if (pendingTargets != null && !pendingTargets.isEmpty()) {
serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets); serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets);
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent); startForegroundService(serviceIntent);
} else { } else {

View File

@@ -1,5 +1,6 @@
package com.foxx.androidcast.sender; package com.foxx.androidcast.sender;
import android.graphics.Bitmap;
import android.media.MediaCodec; import android.media.MediaCodec;
import android.media.MediaCodecInfo; import android.media.MediaCodecInfo;
import android.media.MediaFormat; import android.media.MediaFormat;
@@ -9,6 +10,7 @@ import android.util.Log;
import android.view.Surface; import android.view.Surface;
import com.foxx.androidcast.CastTuningEngine; import com.foxx.androidcast.CastTuningEngine;
import com.foxx.androidcast.media.RgbToYuv420;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -25,6 +27,9 @@ public class VideoEncoder {
private MediaCodec codec; private MediaCodec codec;
private Surface inputSurface; private Surface inputSurface;
private boolean bufferInput;
private RgbToYuv420.Layout yuvLayout = RgbToYuv420.Layout.I420;
private byte[] yuvScratch;
private Callback callback; private Callback callback;
private int width; private int width;
private int height; private int height;
@@ -32,7 +37,10 @@ public class VideoEncoder {
private Thread drainThread; private Thread drainThread;
private String mime; private String mime;
/** Request an IDR (keyframe) on the next opportunity. */ public Surface getInputSurface() {
return inputSurface;
}
public void requestKeyframe() { public void requestKeyframe() {
if (codec == null || !running) { if (codec == null || !running) {
return; return;
@@ -48,19 +56,36 @@ public class VideoEncoder {
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params, public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException { Callback callback) throws IOException {
return prepareInternal(width, height, mime, params, callback, false);
}
/** Software-fed frames (calibration chart) via YUV buffers — no input Surface. */
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException {
prepareInternal(width, height, mime, params, callback, true);
}
private Surface prepareInternal(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback, boolean buffer) throws IOException {
this.width = width; this.width = width;
this.height = height; this.height = height;
this.mime = mime; this.mime = mime;
this.callback = callback; this.callback = callback;
this.bufferInput = buffer;
MediaFormat tuned = buildFormat(width, height, mime, params, true); yuvScratch = buffer ? new byte[RgbToYuv420.bufferSize(width, height)] : null;
MediaFormat basic = buildFormat(width, height, mime, params, false);
codec = MediaCodec.createEncoderByType(mime); codec = MediaCodec.createEncoderByType(mime);
yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
MediaFormat tuned = buildFormat(width, height, mime, params, true, buffer, yuvLayout);
MediaFormat basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
if (!tryConfigure(codec, tuned)) { if (!tryConfigure(codec, tuned)) {
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format"); Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
codec.release(); codec.release();
codec = MediaCodec.createEncoderByType(mime); codec = MediaCodec.createEncoderByType(mime);
yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
if (!tryConfigure(codec, basic)) { if (!tryConfigure(codec, basic)) {
codec.release(); codec.release();
codec = null; codec = null;
@@ -68,20 +93,73 @@ public class VideoEncoder {
} }
} }
inputSurface = codec.createInputSurface(); if (bufferInput) {
inputSurface = null;
} else {
inputSurface = codec.createInputSurface();
}
codec.start(); codec.start();
running = true; running = true;
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain"); drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
drainThread.start(); drainThread.start();
Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps " Log.i(TAG, (bufferInput ? "Buffer/" + yuvLayout : "Surface") + " encoder " + mime
+ (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec); + " " + width + "x" + height + " @ " + params.videoFps + "fps");
return inputSurface; return inputSurface;
} }
private static RgbToYuv420.Layout pickYuvLayout(MediaCodec codec, String mime) {
MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime);
for (int fmt : caps.colorFormats) {
if (fmt == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) {
return RgbToYuv420.Layout.NV12;
}
}
return RgbToYuv420.Layout.I420;
}
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
if (!bufferInput || codec == null || !running || bitmap == null) {
return;
}
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, yuvLayout);
queueYuvFrame(yuv, ptsUs);
}
public void queueYuvFrame(byte[] yuv, long ptsUs) {
if (!bufferInput || codec == null || !running || yuv == null) {
return;
}
try {
int index = codec.dequeueInputBuffer(0);
if (index < 0) {
return;
}
ByteBuffer buffer = codec.getInputBuffer(index);
if (buffer == null) {
return;
}
buffer.clear();
int len = Math.min(buffer.remaining(), yuv.length);
buffer.put(yuv, 0, len);
codec.queueInputBuffer(index, 0, len, ptsUs, 0);
} catch (Exception e) {
Log.w(TAG, "queueYuvFrame: " + e.getMessage());
}
}
private static MediaFormat buildFormat(int width, int height, String mime, private static MediaFormat buildFormat(int width, int height, String mime,
CastTuningEngine.EffectiveParams params, boolean tuned) { CastTuningEngine.EffectiveParams params, boolean tuned, boolean buffer,
RgbToYuv420.Layout layout) {
MediaFormat format = MediaFormat.createVideoFormat(mime, width, height); MediaFormat format = MediaFormat.createVideoFormat(mime, width, height);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); int color;
if (!buffer) {
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface;
} else if (layout == RgbToYuv420.Layout.NV12) {
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar;
} else {
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar;
}
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, color);
format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate); format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate);
format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps); format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec);
@@ -117,6 +195,7 @@ public class VideoEncoder {
public void stop() { public void stop() {
running = false; running = false;
bufferInput = false;
if (drainThread != null) { if (drainThread != null) {
try { try {
drainThread.join(1500); drainThread.join(1500);
@@ -136,25 +215,30 @@ public class VideoEncoder {
inputSurface.release(); inputSurface.release();
inputSurface = null; inputSurface = null;
} }
yuvScratch = null;
} }
private void drainLoop() { private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean configSent = false; boolean configSent = false;
while (running) { while (running) {
int index = codec.dequeueOutputBuffer(info, 5_000); MediaCodec c = codec;
if (c == null) {
break;
}
int index = c.dequeueOutputBuffer(info, 5_000);
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) { if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
continue; continue;
} }
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
sendConfig(codec.getOutputFormat(), configSent); sendConfig(c.getOutputFormat(), configSent);
configSent = true; configSent = true;
continue; continue;
} }
if (index < 0) { if (index < 0) {
continue; continue;
} }
ByteBuffer buffer = codec.getOutputBuffer(index); ByteBuffer buffer = c.getOutputBuffer(index);
if (buffer != null && info.size > 0) { if (buffer != null && info.size > 0) {
byte[] data = new byte[info.size]; byte[] data = new byte[info.size];
buffer.position(info.offset); buffer.position(info.offset);
@@ -166,7 +250,7 @@ public class VideoEncoder {
callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data); callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data);
} }
} }
codec.releaseOutputBuffer(index, false); c.releaseOutputBuffer(index, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
break; break;
} }

View File

@@ -0,0 +1,199 @@
package com.foxx.androidcast.sender.calibration;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import android.view.Surface;
import com.foxx.androidcast.media.PcmLevelAnalyzer;
import com.foxx.androidcast.sender.VideoEncoder;
/**
* Renders calibration chart + VU border into the video encoder (GLES surface or YUV buffer fallback).
*/
public final class CalibrationCastDriver {
private static final String TAG = "CalibrationCastDriver";
private static final int EGL_FAIL_THRESHOLD = 5;
public interface FallbackHost {
/** Recreate encoder with {@link VideoEncoder#prepareBufferInput} after GLES failure. */
void onCalibrationNeedBufferEncoder();
}
private enum FeedMode {
GLES_SURFACE,
YUV_BUFFER
}
private final Context appContext;
private final HandlerThread thread;
private final Handler handler;
private final FallbackHost fallbackHost;
private CalibrationEglRenderer eglRenderer;
private VideoEncoder videoEncoder;
private Bitmap chartBitmap;
private Bitmap frameBitmap;
private Canvas frameCanvas;
private int width;
private int height;
private float density;
private int scrollPx;
private volatile float vuLevel;
private volatile boolean running;
private Runnable tick;
private long encodeStartNs;
private FeedMode feedMode = FeedMode.YUV_BUFFER;
private int eglFailures;
private boolean fallbackRequested;
public CalibrationCastDriver(Context context) {
this(context, null);
}
public CalibrationCastDriver(Context context, FallbackHost fallbackHost) {
appContext = context.getApplicationContext();
this.fallbackHost = fallbackHost;
thread = new HandlerThread("CalibrationCast");
thread.start();
handler = new Handler(thread.getLooper());
}
/** Preferred path: GLES into encoder {@link Surface}. */
public void attachSurface(VideoEncoder encoder, Surface encoderSurface, int width, int height, float density) {
this.videoEncoder = encoder;
initBitmaps(width, height, density);
eglFailures = 0;
fallbackRequested = false;
try {
eglRenderer = new CalibrationEglRenderer();
eglRenderer.start(encoderSurface, width, height);
feedMode = FeedMode.GLES_SURFACE;
Log.i(TAG, "Calibration GLES " + width + "x" + height);
} catch (Exception e) {
Log.w(TAG, "GLES init failed: " + e.getMessage());
releaseEgl();
feedMode = FeedMode.YUV_BUFFER;
requestBufferEncoderFallback();
}
running = true;
encodeStartNs = System.nanoTime();
startTick();
}
/** Reliable path when GLES cannot use the encoder surface (e.g. EGL_BAD_ACCESS on MTK). */
public void attachBuffer(VideoEncoder encoder, int width, int height, float density) {
this.videoEncoder = encoder;
releaseEgl();
feedMode = FeedMode.YUV_BUFFER;
initBitmaps(width, height, density);
running = true;
encodeStartNs = System.nanoTime();
startTick();
Log.i(TAG, "Calibration buffer feed " + width + "x" + height);
}
private void initBitmaps(int w, int h, float density) {
this.width = w;
this.height = h;
this.density = density;
scrollPx = 0;
chartBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
Canvas chartCanvas = new Canvas(chartBitmap);
TvCalibrationGenerator.drawChart(chartCanvas, w, h, appContext);
frameBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
frameCanvas = new Canvas(frameBitmap);
}
public void setVuFromPcm(byte[] pcm, int length) {
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, length);
float instant = Math.max(levels.peak, levels.rms);
if (levels.silent) {
vuLevel *= 0.88f;
} else {
vuLevel = instant * 0.7f + vuLevel * 0.3f;
}
}
private void startTick() {
tick = new Runnable() {
@Override
public void run() {
if (!running || frameCanvas == null) {
return;
}
long frameStart = System.nanoTime();
drawFrame();
scrollPx += Math.max(1, Math.round(1f * density));
long elapsedMs = (System.nanoTime() - frameStart) / 1_000_000L;
long delay = feedMode == FeedMode.GLES_SURFACE
? Math.max(1L, 33L - elapsedMs)
: Math.max(1L, 50L - elapsedMs);
handler.postDelayed(this, delay);
}
};
handler.post(tick);
}
private void drawFrame() {
frameCanvas.drawBitmap(chartBitmap, 0f, 0f, null);
TvCalibrationGenerator.drawBorderOverlay(
frameCanvas, width, height, density, scrollPx, vuLevel);
long ptsUs = (System.nanoTime() - encodeStartNs) / 1000L;
if (feedMode == FeedMode.GLES_SURFACE && eglRenderer != null && eglRenderer.isReady()) {
if (eglRenderer.draw(frameBitmap, System.nanoTime())) {
eglFailures = 0;
return;
}
eglFailures++;
if (eglFailures >= EGL_FAIL_THRESHOLD) {
Log.w(TAG, "GLES feed failed " + eglFailures + " times — switching to buffer");
releaseEgl();
feedMode = FeedMode.YUV_BUFFER;
requestBufferEncoderFallback();
}
return;
}
if (videoEncoder != null && feedMode == FeedMode.YUV_BUFFER) {
videoEncoder.queueBitmapFrame(frameBitmap, ptsUs);
}
}
private void requestBufferEncoderFallback() {
if (fallbackRequested || fallbackHost == null) {
return;
}
fallbackRequested = true;
handler.post(() -> fallbackHost.onCalibrationNeedBufferEncoder());
}
private void releaseEgl() {
if (eglRenderer != null) {
eglRenderer.release();
eglRenderer = null;
}
}
public void stop() {
running = false;
handler.removeCallbacksAndMessages(null);
releaseEgl();
if (chartBitmap != null) {
chartBitmap.recycle();
chartBitmap = null;
}
if (frameBitmap != null) {
frameBitmap.recycle();
frameBitmap = null;
}
frameCanvas = null;
videoEncoder = null;
}
public void release() {
stop();
thread.quitSafely();
}
}

View File

@@ -0,0 +1,236 @@
package com.foxx.androidcast.sender.calibration;
import android.graphics.Bitmap;
import android.opengl.EGL14;
import android.opengl.EGLConfig;
import android.opengl.EGLContext;
import android.opengl.EGLDisplay;
import android.opengl.EGLExt;
import android.opengl.EGLSurface;
import android.opengl.GLES20;
import android.opengl.GLUtils;
import android.util.Log;
import android.view.Surface;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
/** Draws a bitmap into a MediaCodec encoder input {@link Surface} via GLES2. */
final class CalibrationEglRenderer {
private static final String TAG = "CalibrationEglRenderer";
private static final String VERT =
"attribute vec4 aPosition;\n"
+ "attribute vec2 aTexCoord;\n"
+ "varying vec2 vTexCoord;\n"
+ "void main() {\n"
+ " gl_Position = aPosition;\n"
+ " vTexCoord = aTexCoord;\n"
+ "}\n";
private static final String FRAG =
"precision mediump float;\n"
+ "varying vec2 vTexCoord;\n"
+ "uniform sampler2D uTexture;\n"
+ "void main() {\n"
+ " gl_FragColor = texture2D(uTexture, vTexCoord);\n"
+ "}\n";
private static final float[] QUAD = {
-1f, -1f, 0f, 1f,
1f, -1f, 1f, 1f,
-1f, 1f, 0f, 0f,
1f, 1f, 1f, 0f,
};
private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY;
private EGLContext eglContext = EGL14.EGL_NO_CONTEXT;
private EGLSurface eglSurface = EGL14.EGL_NO_SURFACE;
private int program;
private int textureId;
private int aPosition;
private int aTexCoord;
private int uTexture;
private FloatBuffer quadBuffer;
private int width;
private int height;
/** {@link EGLExt#EGL_RECORDABLE_ANDROID} — required for MediaCodec input surfaces. */
private static final int EGL_RECORDABLE_ANDROID = 0x3142;
boolean isReady() {
return eglDisplay != EGL14.EGL_NO_DISPLAY && eglSurface != EGL14.EGL_NO_SURFACE;
}
void start(Surface surface, int width, int height) {
this.width = width;
this.height = height;
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
throw new IllegalStateException("eglGetDisplay failed");
}
int[] version = new int[2];
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
throw new IllegalStateException("eglInitialize failed");
}
EGLConfig eglConfig = chooseConfig(eglDisplay);
int[] contextAttribs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0);
if (eglContext == EGL14.EGL_NO_CONTEXT) {
throw new IllegalStateException("eglCreateContext failed");
}
int[] surfaceAttribs = {EGL14.EGL_NONE};
eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttribs, 0);
if (eglSurface == EGL14.EGL_NO_SURFACE) {
throw new IllegalStateException("eglCreateWindowSurface failed");
}
if (!makeCurrent()) {
throw new IllegalStateException("eglMakeCurrent failed: 0x"
+ Integer.toHexString(EGL14.eglGetError()));
}
program = buildProgram(VERT, FRAG);
aPosition = GLES20.glGetAttribLocation(program, "aPosition");
aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord");
uTexture = GLES20.glGetUniformLocation(program, "uTexture");
int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
textureId = textures[0];
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
quadBuffer.put(QUAD).position(0);
GLES20.glViewport(0, 0, width, height);
Log.i(TAG, "EGL calibration renderer " + width + "x" + height);
}
/** @return false if EGL could not present this frame (caller should fall back to buffer input). */
boolean draw(Bitmap bitmap, long ptsNs) {
if (eglDisplay == EGL14.EGL_NO_DISPLAY || bitmap == null) {
return false;
}
if (!makeCurrent()) {
return false;
}
GLES20.glViewport(0, 0, width, height);
GLES20.glClearColor(0f, 0f, 0f, 1f);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(program);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
GLES20.glUniform1i(uTexture, 0);
quadBuffer.position(0);
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
quadBuffer.position(2);
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
GLES20.glEnableVertexAttribArray(aPosition);
GLES20.glEnableVertexAttribArray(aTexCoord);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
GLES20.glDisableVertexAttribArray(aPosition);
GLES20.glDisableVertexAttribArray(aTexCoord);
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, ptsNs);
if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) {
Log.w(TAG, "eglSwapBuffers failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
return false;
}
return true;
}
private boolean makeCurrent() {
if (eglContext.equals(EGL14.eglGetCurrentContext())
&& eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW))) {
return true;
}
if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
Log.w(TAG, "eglMakeCurrent failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
return false;
}
return true;
}
void release() {
if (textureId != 0) {
int[] t = new int[] {textureId};
GLES20.glDeleteTextures(1, t, 0);
textureId = 0;
}
if (program != 0) {
GLES20.glDeleteProgram(program);
program = 0;
}
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT);
if (eglSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, eglSurface);
}
if (eglContext != EGL14.EGL_NO_CONTEXT) {
EGL14.eglDestroyContext(eglDisplay, eglContext);
}
EGL14.eglTerminate(eglDisplay);
}
eglDisplay = EGL14.EGL_NO_DISPLAY;
eglContext = EGL14.EGL_NO_CONTEXT;
eglSurface = EGL14.EGL_NO_SURFACE;
}
private static EGLConfig chooseConfig(EGLDisplay display) {
int[] attribList = {
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL_RECORDABLE_ANDROID, 1,
EGL14.EGL_NONE
};
EGLConfig[] configs = new EGLConfig[16];
int[] numConfigs = new int[1];
if (!EGL14.eglChooseConfig(display, attribList, 0, configs, 0, configs.length, numConfigs, 0)
|| numConfigs[0] <= 0) {
throw new IllegalStateException("eglChooseConfig failed (recordable)");
}
for (int i = 0; i < numConfigs[0]; i++) {
int[] recordable = new int[1];
if (EGL14.eglGetConfigAttrib(display, configs[i], EGL_RECORDABLE_ANDROID, recordable, 0)
&& recordable[0] != 0) {
return configs[i];
}
}
return configs[0];
}
private static int buildProgram(String vertexSource, String fragmentSource) {
int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource);
int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
int program = GLES20.glCreateProgram();
GLES20.glAttachShader(program, vs);
GLES20.glAttachShader(program, fs);
GLES20.glLinkProgram(program);
int[] link = new int[1];
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, link, 0);
if (link[0] == 0) {
String err = GLES20.glGetProgramInfoLog(program);
GLES20.glDeleteProgram(program);
throw new IllegalStateException("Program link failed: " + err);
}
GLES20.glDeleteShader(vs);
GLES20.glDeleteShader(fs);
return program;
}
private static int compileShader(int type, String source) {
int shader = GLES20.glCreateShader(type);
GLES20.glShaderSource(shader, source);
GLES20.glCompileShader(shader);
int[] compiled = new int[1];
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
if (compiled[0] == 0) {
String err = GLES20.glGetShaderInfoLog(shader);
GLES20.glDeleteShader(shader);
throw new IllegalStateException("Shader compile failed: " + err);
}
return shader;
}
}

View File

@@ -0,0 +1,159 @@
package com.foxx.androidcast.sender.calibration;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import com.foxx.androidcast.R;
/**
* Draws a TV calibration chart (color bars + pluge) with gray reference areas transparent.
*/
public final class TvCalibrationGenerator {
private static final int[] BAR_COLORS = {
Color.WHITE, Color.YELLOW, Color.CYAN, Color.GREEN,
Color.MAGENTA, Color.RED, Color.BLUE
};
private TvCalibrationGenerator() {}
public static void drawChart(Canvas canvas, int width, int height, Context context) {
if (context == null) {
drawProceduralChart(canvas, width, height);
return;
}
canvas.drawColor(Color.BLACK);
Bitmap ref = loadReference(context);
if (ref != null) {
drawReferenceWithTransparentGray(canvas, ref, width, height);
ref.recycle();
return;
}
drawProceduralChart(canvas, width, height);
}
private static Bitmap loadReference(Context context) {
try {
return BitmapFactory.decodeResource(context.getResources(), R.drawable.calibration_chart_reference);
} catch (Exception e) {
return null;
}
}
private static void drawReferenceWithTransparentGray(Canvas canvas, Bitmap ref, int w, int h) {
Bitmap scaled = Bitmap.createScaledBitmap(ref, w, h, true);
Bitmap keyed = scaled.copy(Bitmap.Config.ARGB_8888, true);
scaled.recycle();
int[] pixels = new int[w * h];
keyed.getPixels(pixels, 0, w, 0, 0, w, h);
for (int i = 0; i < pixels.length; i++) {
int p = pixels[i];
int r = (p >> 16) & 0xff;
int g = (p >> 8) & 0xff;
int b = p & 0xff;
if (isGray(r, g, b)) {
pixels[i] = 0;
}
}
keyed.setPixels(pixels, 0, w, 0, 0, w, h);
canvas.drawBitmap(keyed, 0, 0, null);
keyed.recycle();
}
private static boolean isGray(int r, int g, int b) {
return Math.abs(r - g) < 25 && Math.abs(g - b) < 25 && r > 40 && r < 200;
}
private static void drawProceduralChart(Canvas canvas, int width, int height) {
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
int barH = (int) (height * 0.55f);
int barW = width / BAR_COLORS.length;
for (int i = 0; i < BAR_COLORS.length; i++) {
paint.setColor(BAR_COLORS[i]);
canvas.drawRect(i * barW, 0, (i + 1) * barW, barH, paint);
}
int lowerTop = barH;
int cols = 8;
int cellW = width / cols;
for (int c = 0; c < cols; c++) {
int level = 255 - (c * 255 / (cols - 1));
if (c == 0 || c == cols - 1) {
continue;
}
paint.setColor(Color.rgb(level, level, level));
canvas.drawRect(c * cellW, lowerTop, (c + 1) * cellW, height, paint);
}
paint.setColor(Color.WHITE);
canvas.drawRect(0, lowerTop, cellW, height, paint);
paint.setColor(Color.BLACK);
canvas.drawRect((cols - 1) * cellW, lowerTop, width, height, paint);
}
/** Blue/white vertical stripe border with optional VU-driven stripe width (8dp border). */
public static void drawAnimatedBorder(Canvas canvas, int width, int height, float density,
int scrollPx, float vuLevel, Context context) {
drawChart(canvas, width, height, context);
drawBorderOverlay(canvas, width, height, density, scrollPx, vuLevel);
}
/** Draws only the animated border (chart must already be on the canvas / bitmap). */
public static void drawBorderOverlay(Canvas canvas, int width, int height, float density,
int scrollPx, float vuLevel) {
int borderPx = Math.max(2, Math.round(8f * density));
int stripe = Math.max(1, Math.round(1f * density));
int maxScroll = width + height;
int offset = scrollPx % Math.max(1, maxScroll);
float vu = Math.max(0f, Math.min(1f, vuLevel));
int blueWidth = Math.max(stripe, Math.round(stripe + vu * borderPx * 1.35f));
drawStripeFrame(canvas, width, height, borderPx, stripe, blueWidth, offset);
}
private static void drawStripeFrame(Canvas canvas, int width, int height, int border,
int stripe, int blueWidth, int offset) {
Paint paint = new Paint();
int cycle = stripe + blueWidth;
RectF top = new RectF(0, 0, width, border);
RectF bottom = new RectF(0, height - border, width, height);
RectF left = new RectF(0, 0, border, height);
RectF right = new RectF(width - border, 0, width, height);
fillStripes(canvas, paint, top, true, offset, cycle, blueWidth);
fillStripes(canvas, paint, bottom, true, offset, cycle, blueWidth);
fillStripes(canvas, paint, left, false, offset, cycle, blueWidth);
fillStripes(canvas, paint, right, false, offset, cycle, blueWidth);
}
private static void fillStripes(Canvas canvas, Paint paint, RectF area, boolean horizontal,
int offset, int cycle, int blueWidth) {
if (horizontal) {
int h = Math.max(1, Math.round(area.height()));
for (float x = area.left; x < area.right; ) {
int pos = (int) (x + area.top + offset);
int color = stripeColor(pos, cycle, blueWidth);
float next = Math.min(area.right, x + 1f);
paint.setColor(color);
canvas.drawRect(x, area.top, next, area.top + h, paint);
x = next;
}
} else {
int w = Math.max(1, Math.round(area.width()));
for (float y = area.top; y < area.bottom; ) {
int pos = (int) (area.left + y + offset);
int color = stripeColor(pos, cycle, blueWidth);
float next = Math.min(area.bottom, y + 1f);
paint.setColor(color);
canvas.drawRect(area.left, y, area.left + w, next, paint);
y = next;
}
}
}
private static int stripeColor(int pos, int cycle, int blueWidth) {
int m = pos % cycle;
return m < blueWidth ? Color.rgb(30, 144, 255) : Color.WHITE;
}
}

View File

@@ -0,0 +1,156 @@
package com.foxx.androidcast.sender.camera;
import android.content.Context;
import android.graphics.SurfaceTexture;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCaptureSession;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraDevice;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CaptureRequest;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import android.util.Size;
import android.view.Surface;
import java.util.Collections;
/** Camera2 preview into the video encoder input {@link Surface}. */
public final class CameraCastCapture {
private static final String TAG = "CameraCastCapture";
private final Context appContext;
private HandlerThread thread;
private Handler handler;
private CameraDevice camera;
private CameraCaptureSession session;
private String cameraId;
public CameraCastCapture(Context context) {
appContext = context.getApplicationContext();
}
public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException {
stop();
thread = new HandlerThread("CameraCast");
thread.start();
handler = new Handler(thread.getLooper());
CameraManager mgr = appContext.getSystemService(CameraManager.class);
cameraId = chooseCameraId(mgr);
if (cameraId == null) {
throw new CameraAccessException(CameraAccessException.CAMERA_ERROR,
"No back camera available");
}
Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight);
Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight());
mgr.openCamera(cameraId, new CameraDevice.StateCallback() {
@Override
public void onOpened(CameraDevice device) {
camera = device;
try {
createSession(device, encoderSurface, preview);
} catch (CameraAccessException e) {
Log.e(TAG, "Session failed", e);
stop();
}
}
@Override
public void onDisconnected(CameraDevice device) {
device.close();
camera = null;
}
@Override
public void onError(CameraDevice device, int error) {
Log.e(TAG, "Camera error " + error);
device.close();
camera = null;
}
}, handler);
}
private void createSession(CameraDevice device, Surface encoderSurface, Size preview)
throws CameraAccessException {
CaptureRequest.Builder builder = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
builder.addTarget(encoderSurface);
device.createCaptureSession(Collections.singletonList(encoderSurface),
new CameraCaptureSession.StateCallback() {
@Override
public void onConfigured(CameraCaptureSession captureSession) {
session = captureSession;
try {
builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO);
captureSession.setRepeatingRequest(builder.build(), null, handler);
Log.i(TAG, "Camera preview running");
} catch (CameraAccessException e) {
Log.e(TAG, "Repeating request failed", e);
}
}
@Override
public void onConfigureFailed(CameraCaptureSession captureSession) {
Log.e(TAG, "Camera session configure failed");
}
}, handler);
}
private static String chooseCameraId(CameraManager mgr) throws CameraAccessException {
for (String id : mgr.getCameraIdList()) {
CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
return id;
}
}
String[] ids = mgr.getCameraIdList();
return ids.length > 0 ? ids[0] : null;
}
private static Size choosePreviewSize(CameraManager mgr, String id, int targetW, int targetH)
throws CameraAccessException {
CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
StreamConfigurationMap map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
if (map == null) {
return new Size(targetW, targetH);
}
Size[] sizes = map.getOutputSizes(SurfaceTexture.class);
if (sizes == null || sizes.length == 0) {
return new Size(targetW, targetH);
}
Size best = sizes[0];
long bestScore = Long.MAX_VALUE;
for (Size s : sizes) {
long score = Math.abs((long) s.getWidth() * s.getHeight() - (long) targetW * targetH);
if (score < bestScore) {
bestScore = score;
best = s;
}
}
return best;
}
public void stop() {
if (session != null) {
try {
session.close();
} catch (Exception ignored) {
}
session = null;
}
if (camera != null) {
try {
camera.close();
} catch (Exception ignored) {
}
camera = null;
}
if (thread != null) {
thread.quitSafely();
thread = null;
}
handler = null;
}
}

View File

@@ -0,0 +1,204 @@
package com.foxx.androidcast.stats;
import android.content.Context;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.transport.TransportLossStats;
import org.json.JSONObject;
/**
* Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}.
*/
public final class SessionStatsRecorder {
private final long startedMs = System.currentTimeMillis();
private final String role;
private final String transport;
private String videoCodec = "";
private String audioCodec = "AAC";
private String captureMode = "";
private String resolution = "";
private int clientCount = 1;
private int disruptCount;
private int idleTimeouts;
private int networkSamples;
private long sumRttMs;
private long sumLossRatioMicro; // loss * 1e6
private long sumVideoBitrateKbps;
private long sumAudioBitrateKbps;
private long sumEncodeFpsMicro;
private long sumDecodeFpsMicro;
private int bitrateSamples;
private int fpsSamples;
private final TransportLossStats lossTotals = new TransportLossStats();
public SessionStatsRecorder(String role, String transport) {
this.role = role;
this.transport = transport != null ? transport : "unknown";
}
public void setClientCount(int count) {
if (count > 0) {
clientCount = count;
}
}
public void setCodecs(String videoMime, String audioMime) {
if (videoMime != null) {
videoCodec = videoMime;
}
if (audioMime != null) {
audioCodec = audioMime;
}
}
public void setCaptureMode(String mode) {
captureMode = mode != null ? mode : "";
}
public void setResolution(int w, int h) {
if (w > 0 && h > 0) {
resolution = w + "x" + h;
}
}
public void noteDisrupt(String reason) {
disruptCount++;
}
public void noteIdleTimeout() {
idleTimeouts++;
disruptCount++;
}
public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
networkSamples++;
NetworkStatsSnapshot s = local != null ? local : peer;
if (s == null) {
return;
}
if (s.rttMs > 0) {
sumRttMs += s.rttMs;
}
if (s.lossRatio > 0) {
sumLossRatioMicro += (long) (s.lossRatio * 1_000_000);
}
if (s.sendBitrateKbps > 0) {
sumVideoBitrateKbps += s.sendBitrateKbps;
bitrateSamples++;
} else if (s.recvBitrateKbps > 0) {
sumVideoBitrateKbps += s.recvBitrateKbps;
bitrateSamples++;
}
if (s.encodeFps > 0) {
sumEncodeFpsMicro += (long) (s.encodeFps * 1000);
fpsSamples++;
}
if (s.targetBitrateKbps > 0) {
sumAudioBitrateKbps += Math.min(s.targetBitrateKbps, 256);
}
}
/** Final transport/app loss snapshot for this session (not accumulated over time). */
public void mergeLoss(TransportLossStats loss) {
if (loss == null) {
return;
}
synchronized (lossTotals) {
lossTotals.datagramsSent = loss.datagramsSent;
lossTotals.datagramsReceived = loss.datagramsReceived;
lossTotals.datagramsInvalid = loss.datagramsInvalid;
lossTotals.fragmentsReceived = loss.fragmentsReceived;
lossTotals.fragmentsDuplicate = loss.fragmentsDuplicate;
lossTotals.reassemblyCompleted = loss.reassemblyCompleted;
lossTotals.reassemblyIncomplete = loss.reassemblyIncomplete;
lossTotals.reassemblySuperseded = loss.reassemblySuperseded;
lossTotals.wirePacketsDropped = loss.wirePacketsDropped;
lossTotals.malformedPackets = loss.malformedPackets;
lossTotals.sendDroppedVideoP = loss.sendDroppedVideoP;
lossTotals.sendDroppedAudio = loss.sendDroppedAudio;
lossTotals.recvVideoSkippedAwaitingKey = loss.recvVideoSkippedAwaitingKey;
lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
lossTotals.protectionMode = loss.protectionMode;
}
}
public void mergeSettings(CastSettings settings) {
if (settings == null) {
return;
}
captureMode = settings.getCaptureMode() != null ? settings.getCaptureMode().name() : captureMode;
if (settings.getNegotiatedVideoMime() != null) {
videoCodec = settings.getNegotiatedVideoMime();
}
}
public void finish(Context context) {
long endedMs = System.currentTimeMillis();
try {
JSONObject root = new JSONObject();
root.put("schema_version", 1);
root.put("started_at_epoch_ms", startedMs);
root.put("ended_at_epoch_ms", endedMs);
root.put("duration_ms", endedMs - startedMs);
root.put("role", role);
root.put("transport", transport);
root.put("client_count", clientCount);
root.put("disrupt_count", disruptCount);
root.put("idle_timeout_count", idleTimeouts);
root.put("video_codec", videoCodec);
root.put("audio_codec", audioCodec);
root.put("capture_mode", captureMode);
root.put("resolution", resolution);
root.put("network_samples", networkSamples);
if (networkSamples > 0) {
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
}
if (bitrateSamples > 0) {
root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples);
}
if (fpsSamples > 0) {
root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples));
}
JSONObject protection = new JSONObject();
protection.put("mode", lossTotals.protectionMode.name());
protection.put("fec", "stub_disabled");
protection.put("nack", "stub_disabled");
protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
root.put("stream_protection", protection);
JSONObject udp = new JSONObject();
synchronized (lossTotals) {
udp.put("datagrams_tx", lossTotals.datagramsSent);
udp.put("datagrams_rx", lossTotals.datagramsReceived);
udp.put("datagrams_invalid", lossTotals.datagramsInvalid);
udp.put("fragments_rx", lossTotals.fragmentsReceived);
udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate);
udp.put("reassembly_ok", lossTotals.reassemblyCompleted);
udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded);
udp.put("wire_dropped", lossTotals.wirePacketsDropped);
udp.put("malformed", lossTotals.malformedPackets);
udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP);
udp.put("send_dropped_audio", lossTotals.sendDroppedAudio);
udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey);
udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors);
udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
}
root.put("udp", udp);
SessionStatsStore.saveSession(context, startedMs, root);
} catch (Exception ignored) {
}
}
}

View File

@@ -0,0 +1,73 @@
package com.foxx.androidcast.stats;
import android.content.Context;
import com.foxx.androidcast.CastConfig;
import org.json.JSONObject;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/** Rotating flat JSON session logs in app-private storage. */
public final class SessionStatsStore {
private static final String DIR_NAME = "session_stats";
private static final String PREFIX = "session_";
private static final String SUFFIX = ".json";
private SessionStatsStore() {}
public static File statsDir(Context context) {
return new File(context.getApplicationContext().getFilesDir(), DIR_NAME);
}
/** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */
public static void pruneOnAppStart(Context context) {
pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
}
/** Deletes oldest session JSON files until count is below {@code maxFiles}. */
static void pruneDirectory(File dir, int maxFiles) {
if (dir == null || !dir.exists()) {
return;
}
File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX));
if (files == null || files.length == 0) {
return;
}
List<File> list = new ArrayList<>();
Collections.addAll(list, files);
Collections.sort(list, Comparator.comparing(File::getName));
while (list.size() >= maxFiles) {
File oldest = list.remove(0);
if (!oldest.delete()) {
break;
}
}
}
public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception {
File dir = statsDir(context);
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("Cannot create session_stats dir");
}
String name = PREFIX + formatTimestamp(startedMs) + SUFFIX;
File out = new File(dir, name);
byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
try (FileOutputStream fos = new FileOutputStream(out)) {
fos.write(bytes);
}
}
private static String formatTimestamp(long epochMs) {
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
}
}

View File

@@ -29,12 +29,19 @@ public class AspectRatioFrameLayout extends FrameLayout {
@Override @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (aspectRatio <= 0f || getChildCount() == 0) {
return;
}
int parentW = MeasureSpec.getSize(widthMeasureSpec); int parentW = MeasureSpec.getSize(widthMeasureSpec);
int parentH = MeasureSpec.getSize(heightMeasureSpec); int parentH = MeasureSpec.getSize(heightMeasureSpec);
if (getChildCount() == 0) {
setMeasuredDimension(parentW, parentH);
return;
}
if (aspectRatio <= 0f) {
int fullW = MeasureSpec.makeMeasureSpec(parentW, MeasureSpec.EXACTLY);
int fullH = MeasureSpec.makeMeasureSpec(parentH, MeasureSpec.EXACTLY);
measureChild(getChildAt(0), fullW, fullH);
setMeasuredDimension(parentW, parentH);
return;
}
int childW; int childW;
int childH; int childH;
float parentRatio = (float) parentW / parentH; float parentRatio = (float) parentW / parentH;
@@ -56,6 +63,10 @@ public class AspectRatioFrameLayout extends FrameLayout {
if (getChildCount() == 0) { if (getChildCount() == 0) {
return; return;
} }
if (aspectRatio <= 0f) {
getChildAt(0).layout(left, top, right, bottom);
return;
}
int parentW = right - left; int parentW = right - left;
int parentH = bottom - top; int parentH = bottom - top;
int childW = getChildAt(0).getMeasuredWidth(); int childW = getChildAt(0).getMeasuredWidth();

View File

@@ -0,0 +1,41 @@
package com.foxx.androidcast.ui;
import android.graphics.Matrix;
import android.view.TextureView;
/**
* Letterboxes decoded video in a {@link TextureView}.
* Uses view-space scale around center (same approach as ExoPlayer), not pixel {@link Matrix#setRectToRect}.
*/
public final class PlaybackTextureTransform {
private PlaybackTextureTransform() {}
public static void applyFitCenter(TextureView view, int videoWidth, int videoHeight) {
if (view == null || videoWidth <= 0 || videoHeight <= 0) {
return;
}
int viewW = view.getWidth();
int viewH = view.getHeight();
if (viewW <= 0 || viewH <= 0) {
return;
}
float videoAspect = (float) videoWidth / videoHeight;
float viewAspect = (float) viewW / viewH;
float scaleX = 1f;
float scaleY = 1f;
if (videoAspect > viewAspect) {
scaleY = viewAspect / videoAspect;
} else {
scaleX = videoAspect / viewAspect;
}
Matrix matrix = new Matrix();
matrix.setScale(scaleX, scaleY, viewW / 2f, viewH / 2f);
view.setTransform(matrix);
}
public static void clearTransform(TextureView view) {
if (view != null) {
view.setTransform(new Matrix());
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

@@ -16,7 +16,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="center"> android:layout_gravity="center">
<SurfaceView <TextureView
android:id="@+id/surface_playback" android:id="@+id/surface_playback"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@@ -3,11 +3,12 @@
<string name="app_name">Android Cast</string> <string name="app_name">Android Cast</string>
<string name="send_screen">Send screen (tablet / phone)</string> <string name="send_screen">Send screen (tablet / phone)</string>
<string name="receive_screen">Receive on TV / projector</string> <string name="receive_screen">Receive on TV / projector</string>
<string name="main_hint">Same WiFi required. Default PIN: 1234. Match transport (TCP/UDP) on both devices.</string> <string name="main_hint">Same WiFi required. Default PIN: 1234. UDP is default; pick TCP in settings if needed. Match transport on both devices.</string>
<string name="sender_title">Cast to receiver</string> <string name="sender_title">Cast to receiver</string>
<string name="receiver_title">Waiting for cast</string> <string name="receiver_title">Waiting for cast</string>
<string name="pin_hint">PIN</string> <string name="pin_hint">PIN</string>
<string name="refresh">Refresh</string> <string name="refresh">REFRESH</string>
<string name="refresh_countdown">REFRESH (%1$d)</string>
<string name="start_cast">Start cast</string> <string name="start_cast">Start cast</string>
<string name="searching">Searching for receivers…</string> <string name="searching">Searching for receivers…</string>
<string name="waiting_sender">Waiting for sender…</string> <string name="waiting_sender">Waiting for sender…</string>
@@ -25,8 +26,16 @@
<string name="audio_unavailable_title">Video only</string> <string name="audio_unavailable_title">Video only</string>
<string name="audio_unavailable_message">This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only.</string> <string name="audio_unavailable_message">This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only.</string>
<string name="continue_video_only">Continue (video only)</string> <string name="continue_video_only">Continue (video only)</string>
<string name="transport_tcp">TCP (reliable)</string> <string name="transport_udp">UDP (default, best effort)</string>
<string name="transport_udp">UDP (best effort)</string> <string name="transport_tcp">TCP (reliable fallback)</string>
<string name="transport_quic">QUIC experimental (Cronet)</string>
<string name="transport_webrtc">WebRTC (stub, future)</string>
<string name="capture_calibration_test">Calibration test pattern</string>
<string name="capture_camera">Camera (rear Camera2)</string>
<string name="capture_camera_unavailable">Camera source (requires Android 10+)</string>
<string name="capture_camera_not_implemented">Camera capture is not implemented yet. Use screen or calibration test.</string>
<string name="casting_calibration">Calibration cast %1$dx%2$d</string>
<string name="casting_camera">Camera cast %1$dx%2$d</string>
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string> <string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
<string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string> <string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string>
<string name="quality_high">High (~10 Mbps, 30 fps)</string> <string name="quality_high">High (~10 Mbps, 30 fps)</string>

View File

@@ -0,0 +1,17 @@
package com.foxx.androidcast;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class CastResolutionTest {
@Test
public void calibrationTestSizeIsLandscape16x9() {
CastResolution.Size size = CastResolution.calibrationTestSize();
assertEquals(960, size.width);
assertEquals(540, size.height);
assertTrue(size.width > size.height);
}
}

View File

@@ -0,0 +1,16 @@
package com.foxx.androidcast.media;
import static org.junit.Assert.assertTrue;
import android.media.MediaFormat;
import org.junit.Test;
public class EncodedStreamProbeTest {
@Test
public void hevcKeyframeReportsNalType() {
byte[] vps = {0, 0, 0, 1, 0x40, 0x01, 0x0c, 0x01};
String desc = EncodedStreamProbe.describeKeyframe(MediaFormat.MIMETYPE_VIDEO_HEVC, vps);
assertTrue(desc.contains("HEVC"));
}
}

View File

@@ -0,0 +1,16 @@
package com.foxx.androidcast.media;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class H264BitstreamHevcTest {
@Test
public void avccHevcNalGetsStartCode() {
byte[] vps = {0, 0, 0, 4, 0x40, 0x01, 0x0c, 0x01};
byte[] annexB = H264Bitstream.toAnnexB(vps);
assertTrue(annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1);
assertEquals(0x40, annexB[4] & 0xff);
}
}

View File

@@ -0,0 +1,34 @@
package com.foxx.androidcast.media;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class RgbToNv21Test {
@Test
public void whitePixelProducesHighLuma() {
int[] argb = {0xFFFFFFFF, 0, 0, 0xFFFFFFFF};
byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2);
int y = nv21[0] & 0xff;
assertTrue(y > 220);
}
@Test
public void blackPixelProducesLowLuma() {
int[] argb = {0xFF000000, 0, 0, 0xFF000000};
byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2);
int y = nv21[0] & 0xff;
assertEquals(16, y);
}
@Test
public void bufferSizeMatchesNv21Layout() {
int w = 16;
int h = 16;
int[] argb = new int[w * h];
byte[] nv21 = RgbToNv21.fromArgb(argb, w, h);
assertEquals(w * h + w * h / 2, nv21.length);
}
}

View File

@@ -0,0 +1,25 @@
package com.foxx.androidcast.media;
import org.junit.Test;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertEquals;
public class RgbToYuv420Test {
@Test
public void redPixelDiffersBetweenYAndChromaPlanes() {
int[] argb = {0xFFFF0000, 0, 0, 0xFFFF0000};
byte[] i420 = RgbToYuv420.fromArgb(argb, 2, 2, RgbToYuv420.Layout.I420);
int y = i420[0] & 0xff;
int u = i420[4] & 0xff;
int v = i420[5] & 0xff;
assertNotEquals(y, u);
assertNotEquals(y, v);
}
@Test
public void bufferSizeMatchesI420Layout() {
assertEquals(960 * 540 + (960 * 540) / 2, RgbToYuv420.bufferSize(960, 540));
}
}

View File

@@ -0,0 +1,34 @@
package com.foxx.androidcast.network;
import org.junit.Test;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class CastProtoHeaderTest {
@Test
public void encodeDecodeRoundTrip() throws Exception {
CastProtoHeader original = CastProtoHeader.forCast(0x12345678, 0xABCDEF01, 0x0F, 0xF0);
byte[] wire = original.encode();
CastProtoHeader decoded = CastProtoHeader.decode(wire, 0, wire.length);
assertEquals(original.sessionId, decoded.sessionId);
assertEquals(original.placeholderId, decoded.placeholderId);
assertEquals(original.videoCodecs, decoded.videoCodecs);
assertEquals(original.audioCodecs, decoded.audioCodecs);
assertTrue(decoded.verifyChecksum());
}
@Test
public void frameUnframePreservesPayload() throws Exception {
CastProtoHeader hdr = CastProtoHeader.forCast(99, 1, 2, 3);
byte[] payload = new byte[] {1, 2, 3, 4};
byte[] wire = CastPacketFramer.frame(hdr, (byte) 7, payload);
CastProtoHeader fromWire = CastPacketFramer.readHeader(wire);
assertEquals(99, fromWire.sessionId);
CastProtocol.Message msg = CastPacketFramer.unframe(wire);
assertEquals(7, msg.type);
assertArrayEquals(payload, msg.payload);
}
}

View File

@@ -0,0 +1,49 @@
package com.foxx.androidcast.network;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CastSessionGateTest {
@Test
public void bindsFirstNonZeroSession() {
CastSessionGate gate = new CastSessionGate();
CastProtoHeader first = new CastProtoHeader();
first.sessionId = 42;
assertTrue(gate.accept(first));
assertEquals(42, gate.getBoundSessionId());
CastProtoHeader stale = new CastProtoHeader();
stale.sessionId = 99;
assertFalse(gate.accept(stale));
CastProtoHeader same = new CastProtoHeader();
same.sessionId = 42;
assertTrue(gate.accept(same));
}
@Test
public void zeroSessionAlwaysAccepted() {
CastSessionGate gate = new CastSessionGate();
CastProtoHeader zero = new CastProtoHeader();
zero.sessionId = 0;
assertTrue(gate.accept(zero));
assertFalse(gate.isBound());
}
@Test
public void resetAllowsRebind() {
CastSessionGate gate = new CastSessionGate();
CastProtoHeader a = new CastProtoHeader();
a.sessionId = 1;
gate.accept(a);
gate.reset();
CastProtoHeader b = new CastProtoHeader();
b.sessionId = 2;
assertTrue(gate.accept(b));
assertEquals(2, gate.getBoundSessionId());
}
}

View File

@@ -0,0 +1,42 @@
package com.foxx.androidcast.stats;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import com.foxx.androidcast.CastConfig;
import org.junit.Test;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
public class SessionStatsStoreTest {
@Test
public void pruneDirectory_keepsAtMostMaxMinusOne() throws IOException {
File dir = Files.createTempDirectory("session_stats_test").toFile();
int max = CastConfig.SESSION_STATS_MAX_FILES;
for (int i = 0; i < max + 5; i++) {
File f = new File(dir, String.format("session_20260101_%06d.json", i));
assertTrue(f.createNewFile());
Files.write(f.toPath(), "{}".getBytes(StandardCharsets.UTF_8));
}
assertEquals(max + 5, dir.list((d, n) -> n.endsWith(".json")).length);
SessionStatsStore.pruneDirectory(dir, max);
String[] remaining = dir.list((d, n) -> n.endsWith(".json"));
Arrays.sort(remaining);
assertEquals(max - 1, remaining.length);
assertEquals("session_20260101_000006.json", remaining[0]);
assertEquals("session_20260101_000024.json", remaining[remaining.length - 1]);
for (File f : dir.listFiles()) {
assertTrue(f.delete());
}
assertTrue(dir.delete());
}
}

View File

@@ -0,0 +1,34 @@
package com.foxx.androidcast.ui;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class PlaybackTextureTransformTest {
@Test
public void wideVideoOnWideViewIsUnityScale() {
float[] scales = scalesFor(1920, 1080, 960, 540);
assertEquals(1f, scales[0], 0.01f);
assertEquals(1f, scales[1], 0.01f);
}
@Test
public void tallVideoOnWideViewLetterboxesHorizontally() {
float[] scales = scalesFor(1920, 1080, 540, 960);
assertEquals(0.316f, scales[0], 0.02f);
assertEquals(1f, scales[1], 0.01f);
}
private static float[] scalesFor(int viewW, int viewH, int videoW, int videoH) {
float videoAspect = (float) videoW / videoH;
float viewAspect = (float) viewW / viewH;
float scaleX = 1f;
float scaleY = 1f;
if (videoAspect > viewAspect) {
scaleY = viewAspect / videoAspect;
} else {
scaleX = videoAspect / viewAspect;
}
return new float[] {scaleX, scaleY};
}
}