v2 used a single-byte shard length (max 255), which breaks VP9 keyframes. v3 uses uint16.
*/
public final class FecShardWire {
private static final byte[] MAGIC = {'A', 'C', 'P', 'F'};
- public static final byte VERSION_SHARD = 2;
- /** Bytes after {@link #MAGIC} before shard payload. */
- private static final int HEADER_AFTER_MAGIC = 19;
+ /** Legacy on-wire format; shard length field is 1 byte (max 255). */
+ public static final byte VERSION_SHARD_V2 = 2;
+ /** Current format; shard length field is uint16. */
+ public static final byte VERSION_SHARD = 3;
+ private static final int HEADER_AFTER_MAGIC_V2 = 19;
+ private static final int HEADER_AFTER_MAGIC_V3 = 20;
private FecShardWire() {}
@@ -31,6 +36,9 @@ public final class FecShardWire {
if (shardData == null) {
throw new IOException("shardData required");
}
+ if (shardData.length > 0xFFFF) {
+ throw new IOException("shard too large: " + shardData.length);
+ }
ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length);
DataOutputStream dos = new DataOutputStream(bos);
dos.write(MAGIC);
@@ -43,18 +51,17 @@ public final class FecShardWire {
dos.writeInt(originalLen);
dos.writeByte(dataShards);
dos.writeByte(parityShards);
- dos.writeByte(shardData.length);
+ dos.writeShort(shardData.length);
dos.write(shardData);
return bos.toByteArray();
}
public static boolean isShardPacket(byte[] wire) {
- Parsed p = tryParse(wire);
- return p != null;
+ return tryParse(wire) != null;
}
public static Parsed tryParse(byte[] wire) {
- if (wire == null || wire.length < MAGIC.length + HEADER_AFTER_MAGIC) {
+ if (wire == null || wire.length < MAGIC.length + HEADER_AFTER_MAGIC_V2) {
return null;
}
for (int i = 0; i < MAGIC.length; i++) {
@@ -66,7 +73,7 @@ public final class FecShardWire {
DataInputStream dis = new DataInputStream(new ByteArrayInputStream(wire, MAGIC.length,
wire.length - MAGIC.length));
byte ver = dis.readByte();
- if (ver != VERSION_SHARD) {
+ if (ver != VERSION_SHARD_V2 && ver != VERSION_SHARD) {
return null;
}
byte mode = dis.readByte();
@@ -77,14 +84,15 @@ public final class FecShardWire {
int originalLen = dis.readInt();
int dataShards = dis.readUnsignedByte();
int parityShards = dis.readUnsignedByte();
- int shardSize = dis.readUnsignedByte();
+ int shardSize = ver == VERSION_SHARD ? dis.readUnsignedShort() : dis.readUnsignedByte();
if (dataShards < 1 || parityShards < 1 || totalShards != dataShards + parityShards) {
return null;
}
- if (shardIndex >= totalShards || originalLen < 1) {
+ if (shardIndex >= totalShards || originalLen < 1 || shardSize < 1) {
return null;
}
- if (wire.length < MAGIC.length + HEADER_AFTER_MAGIC + shardSize) {
+ int headerAfterMagic = ver == VERSION_SHARD ? HEADER_AFTER_MAGIC_V3 : HEADER_AFTER_MAGIC_V2;
+ if (wire.length < MAGIC.length + headerAfterMagic + shardSize) {
return null;
}
byte[] shardData = new byte[shardSize];
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
index d6eb1f9..380d78a 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java
@@ -166,21 +166,31 @@ public class AudioDecoder {
Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState());
}
+ /** Drops queued AAC input without tearing down the decoder (e.g. after video keyframe wait). */
+ public void clearInputQueue() {
+ inputQueue.clear();
+ retryFrame = null;
+ }
+
public void queueFrame(long ptsUs, byte[] data) {
if (data == null || data.length == 0) {
return;
}
stats.framesQueued++;
- if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
- PendingFrame dropped = inputQueue.poll();
- if (dropped != null) {
- stats.queueDrops++;
- logDropBurst();
- }
- if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
- stats.queueDrops++;
- logDropBurst();
- }
+ PendingFrame frame = new PendingFrame(ptsUs, data.clone());
+ if (inputQueue.offer(frame)) {
+ return;
+ }
+ int dropped = inputQueue.size() + (retryFrame != null ? 1 : 0);
+ inputQueue.clear();
+ retryFrame = null;
+ if (dropped > 0) {
+ stats.queueDrops += dropped;
+ logDropBurst();
+ }
+ if (!inputQueue.offer(frame)) {
+ stats.queueDrops++;
+ logDropBurst();
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java
index a1c3493..2773c76 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java
@@ -11,8 +11,10 @@ import java.io.IOException;
/** Software VP8/VP9 decode via libvpx, rendering to a {@link Surface}. */
public final class LibvpxVideoDecoder implements VideoDecoderSink {
private static final String TAG = "LibvpxVideoDecoder";
+ private static final int DECODE_FAIL_LOG_EVERY = 16;
private final Object lock = new Object();
+ private int decodeFailLogCount;
private long nativeHandle;
private Surface outputSurface;
private Listener listener;
@@ -47,6 +49,8 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink {
}
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
notifyRenderedLocked();
+ } else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) {
+ Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame);
}
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
index 5c3e62f..d7f5a61 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -25,6 +25,8 @@ import com.foxx.androidcast.IntentExtras;
import com.foxx.androidcast.R;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
+import com.foxx.androidcast.diagnostics.CastMtuSampler;
+import com.foxx.androidcast.diagnostics.CpuLoadSampler;
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.media.codec.VideoCodecBackend;
@@ -169,6 +171,7 @@ public class ReceiverCastService extends Service {
videoDecodeThread = new HandlerThread("VideoDecode");
videoDecodeThread.start();
videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
+ CpuLoadSampler.warmUp();
mainHandler.post(idleWatchdog);
}
@@ -337,7 +340,7 @@ public class ReceiverCastService extends Service {
@Override
public void onAudioFrame(long ptsUs, byte[] data) {
- if (castEnded) {
+ if (castEnded || awaitingKeyframe || streamIdle) {
return;
}
final int frameBytes = data != null ? data.length : 0;
@@ -476,7 +479,7 @@ public class ReceiverCastService extends Service {
return;
}
streamIdle = false;
- awaitingKeyframe = true;
+ enterAwaitingKeyframe();
openPlaybackAwaiting(R.string.playback_awaiting_stream);
scheduleDecoderConfigureRetries();
}
@@ -485,7 +488,7 @@ public class ReceiverCastService extends Service {
private void enterAwaitingMode(int messageResId, boolean fullReset) {
streamIdle = true;
hasPendingConfig = false;
- awaitingKeyframe = true;
+ enterAwaitingKeyframe();
resetDecoderState();
if (videoDecoder != null) {
videoDecoder.release();
@@ -500,7 +503,7 @@ public class ReceiverCastService extends Service {
private void resetStreamState() {
hasPendingConfig = false;
- awaitingKeyframe = true;
+ enterAwaitingKeyframe();
pendingWidth = 0;
pendingHeight = 0;
displayWidth = 0;
@@ -516,6 +519,19 @@ public class ReceiverCastService extends Service {
decoderHeight = 0;
}
+ private void enterAwaitingKeyframe() {
+ awaitingKeyframe = true;
+ flushAudioInput();
+ }
+
+ private void flushAudioInput() {
+ synchronized (audioDecoderLock) {
+ if (audioDecoder != null) {
+ audioDecoder.clearInputQueue();
+ }
+ }
+ }
+
private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) {
pendingAudioRate = sampleRate;
pendingAudioChannels = channels;
@@ -559,7 +575,7 @@ public class ReceiverCastService extends Service {
streamMetrics.reset();
}
reconfiguring = true;
- awaitingKeyframe = true;
+ enterAwaitingKeyframe();
openPlaybackAwaiting(R.string.playback_awaiting_stream);
pendingWidth = size.width;
pendingHeight = size.height;
@@ -796,7 +812,8 @@ public class ReceiverCastService extends Service {
remoteCastSettings,
peer,
lastLocalStats,
- PlaybackViewState.getZoomPercent());
+ PlaybackViewState.getZoomPercent(),
+ CastMtuSampler.sample(this));
}
private TransportLossStats buildReceiverLossSnapshot() {
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
index 48e3702..3efa09f 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
@@ -73,6 +73,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
private boolean bound;
private boolean autoStartListening;
private boolean listeningCancelled;
+ private boolean autoPipAttempted;
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
@Override
@@ -182,11 +183,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
awaitingMessage = findViewById(R.id.text_awaiting_message);
pipButton = findViewById(R.id.button_pip);
if (pipButton != null) {
- pipButton.setOnClickListener(v -> tryEnterPictureInPicture());
+ pipButton.setOnClickListener(v -> tryEnterPictureInPicture(true));
}
textureView.setSurfaceTextureListener(surfaceListener);
applyAspectRatio();
updatePipUi();
+ if (AppPreferences.isReceiverPipEnabled(this) && PictureInPictureHelper.isSupported()) {
+ PictureInPictureHelper.applyParams(this, 16, 9);
+ }
autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false);
if (autoStartListening) {
@@ -245,14 +249,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
mainHandler.post(diagnosticsRefresh);
updateDiagnosticsVisibility();
updatePipUi();
+ maybeAutoEnterPip();
}
@Override
public void onUserLeaveHint() {
super.onUserLeaveHint();
- if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
- && PermissionHelper.hasReceiverPlaybackReady(this)) {
- tryEnterPictureInPicture();
+ if (shouldAutoEnterPipOnLeave()) {
+ tryEnterPictureInPicture(false);
}
}
@@ -268,6 +272,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
updatePipUi();
+ maybeAutoEnterPip();
}
}
@@ -546,7 +551,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
if (pipButton == null) {
return;
}
- boolean show = castSessionActive && AppPreferences.isReceiverPipEnabled(this)
+ boolean sessionVisible = castSessionActive || streamRendering
+ || (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE);
+ boolean show = sessionVisible && AppPreferences.isReceiverPipEnabled(this)
&& PictureInPictureHelper.isSupported()
&& PermissionHelper.hasReceiverPlaybackReady(this);
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
@@ -555,23 +562,64 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
}
}
- private void tryEnterPictureInPicture() {
- if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) {
+ private boolean shouldAutoEnterPipOnLeave() {
+ if (!AppPreferences.isReceiverPipEnabled(this)
+ || !PermissionHelper.hasReceiverPlaybackReady(this)) {
+ return false;
+ }
+ if (castSessionActive || streamRendering) {
+ return true;
+ }
+ return bound && awaitingOverlay != null
+ && awaitingOverlay.getVisibility() == View.VISIBLE;
+ }
+
+ private void maybeAutoEnterPip() {
+ if (!autoStartListening || autoPipAttempted || inPictureInPicture) {
+ return;
+ }
+ if (!AppPreferences.isReceiverPipEnabled(this)
+ || !PictureInPictureHelper.isSupported()) {
return;
}
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
PermissionHelper.remindForReceiverPlayback(this);
return;
}
+ autoPipAttempted = true;
+ mainHandler.post(() -> tryEnterPictureInPicture(false));
+ }
+
+ private void tryEnterPictureInPicture(boolean userInitiated) {
+ if (!AppPreferences.isReceiverPipEnabled(this)) {
+ return;
+ }
+ if (!castSessionActive && !streamRendering
+ && (awaitingOverlay == null || awaitingOverlay.getVisibility() != View.VISIBLE)) {
+ if (userInitiated) {
+ Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
+ }
+ return;
+ }
+ if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
+ if (userInitiated) {
+ PermissionHelper.remindForReceiverPlayback(this);
+ }
+ return;
+ }
if (!PictureInPictureHelper.isSupported()) {
- Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
+ if (userInitiated) {
+ Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
+ }
return;
}
if (!PictureInPictureHelper.isAllowedForApp(this)) {
- PictureInPictureHelper.showEnableInSettingsDialog(this);
+ if (userInitiated) {
+ PictureInPictureHelper.showEnableInSettingsDialog(this);
+ }
return;
}
- if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight)) {
+ if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight) && userInitiated) {
PictureInPictureHelper.showEnableInSettingsDialog(this);
}
}
@@ -582,7 +630,12 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE);
}
if (pipButton != null) {
- pipButton.setVisibility(inPictureInPicture || !castSessionActive ? View.GONE : View.VISIBLE);
+ boolean showPip = (castSessionActive || streamRendering
+ || (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE))
+ && AppPreferences.isReceiverPipEnabled(this)
+ && PictureInPictureHelper.isSupported()
+ && PermissionHelper.hasReceiverPlaybackReady(this);
+ pipButton.setVisibility(inPictureInPicture || !showPip ? View.GONE : View.VISIBLE);
}
if (inPictureInPicture) {
if (diagnosticsText != null) {
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
index 3660440..eb634ea 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
@@ -14,6 +14,8 @@ import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastSessionGate;
import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory;
+import com.foxx.androidcast.network.transport.ProtectionNegotiationPrompts;
+import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
import com.foxx.androidcast.network.UdpCastTransport;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
import com.foxx.androidcast.network.transport.TransportLossStats;
@@ -74,6 +76,7 @@ public class ReceiverSession {
private CastSession session;
private CastTransport listenTransport;
private final CastSessionGate inboundSessionGate = new CastSessionGate();
+ private boolean loggedFirstVideoFrame;
private NetworkFeedbackManager networkFeedback;
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
@@ -195,6 +198,7 @@ public class ReceiverSession {
listenTransport = null;
}
inboundSessionGate.reset();
+ loggedFirstVideoFrame = false;
networkFeedback = null;
listener.onDisconnected();
}
@@ -237,6 +241,12 @@ public class ReceiverSession {
case CastProtocol.MSG_VIDEO_FRAME:
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
recordNetworkBytes(vf.data != null ? vf.data.length : 0);
+ if (!loggedFirstVideoFrame) {
+ loggedFirstVideoFrame = true;
+ Log.i(TAG, "First video over network "
+ + (vf.keyFrame ? "KEY" : "P") + " len="
+ + (vf.data != null ? vf.data.length : 0));
+ }
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
break;
case CastProtocol.MSG_AUDIO_CONFIG:
@@ -286,7 +296,16 @@ public class ReceiverSession {
udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport();
}
if (udp != null) {
- CastTransportFactory.applyNegotiatedUdpProtection(udp, localSettings, remoteSettings);
+ StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection(
+ udp, localSettings, remoteSettings, true);
+ String summary = ProtectionNegotiationPrompts.summarize(appContext, negotiated);
+ Log.i(TAG, summary);
+ postStatus(summary);
+ if (negotiated.prompt != null) {
+ String hint = ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, true);
+ Log.i(TAG, hint);
+ postStatus(hint);
+ }
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
index 4b07f90..771a5c4 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
@@ -88,26 +88,27 @@ public class AudioEncoder implements AudioEncoderSink {
public void stop() {
running = false;
- MediaCodec c;
- synchronized (inputLock) {
- c = codec;
- codec = null;
- }
- if (drainThread != null) {
+ Thread drain = drainThread;
+ drainThread = null;
+ if (drain != null) {
try {
- drainThread.join(1000);
+ drain.join(1000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
- if (c != null) {
- try {
- c.stop();
- } catch (Exception ignored) {
- }
- try {
- c.release();
- } catch (Exception ignored) {
+ synchronized (inputLock) {
+ MediaCodec c = codec;
+ codec = null;
+ if (c != null) {
+ try {
+ c.stop();
+ } catch (Exception ignored) {
+ }
+ try {
+ c.release();
+ } catch (Exception ignored) {
+ }
}
}
}
@@ -115,13 +116,28 @@ public class AudioEncoder implements AudioEncoderSink {
private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean configSent = false;
- while (running) {
- int index = codec.dequeueOutputBuffer(info, 10_000);
+ while (true) {
+ MediaCodec c;
+ synchronized (inputLock) {
+ if (!running) {
+ break;
+ }
+ c = codec;
+ if (c == null) {
+ break;
+ }
+ }
+ int index;
+ try {
+ index = c.dequeueOutputBuffer(info, 10_000);
+ } catch (IllegalStateException e) {
+ break;
+ }
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
continue;
}
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
- MediaFormat fmt = codec.getOutputFormat();
+ MediaFormat fmt = c.getOutputFormat();
if (!configSent && callback != null) {
ByteBuffer csd = fmt.getByteBuffer("csd-0");
callback.onConfig(
@@ -135,7 +151,7 @@ public class AudioEncoder implements AudioEncoderSink {
if (index < 0) {
continue;
}
- ByteBuffer buffer = codec.getOutputBuffer(index);
+ ByteBuffer buffer = c.getOutputBuffer(index);
if (buffer != null && info.size > 0 && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
byte[] data = new byte[info.size];
buffer.position(info.offset);
@@ -145,7 +161,11 @@ public class AudioEncoder implements AudioEncoderSink {
callback.onEncodedFrame(info.presentationTimeUs, data);
}
}
- codec.releaseOutputBuffer(index, false);
+ try {
+ c.releaseOutputBuffer(index, false);
+ } catch (IllegalStateException ignored) {
+ break;
+ }
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
index 8602fc1..5014c65 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
@@ -11,6 +11,8 @@ import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory;
+import com.foxx.androidcast.network.transport.ProtectionNegotiationPrompts;
+import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
import com.foxx.androidcast.network.UdpCastTransport;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
@@ -99,7 +101,7 @@ public final class MultiCastCoordinator {
session = createSession(peerSettings);
connectWithRetry(session, t.host, t.port);
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
- applyNegotiatedProtection(session, peerSettings, hs.settings);
+ applyNegotiatedProtection(session, peerSettings, hs.receiverAdvertisement);
peers.add(new ActivePeer(t, session, hs.settings, hs.negotiatedVideoMime));
Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime
+ " score="
@@ -128,7 +130,7 @@ public final class MultiCastCoordinator {
CastSession session = createSession(peerSettings);
connectWithRetry(session, target.host, target.port);
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
- applyNegotiatedProtection(session, peerSettings, hs.settings);
+ applyNegotiatedProtection(session, peerSettings, hs.receiverAdvertisement);
peers.add(new ActivePeer(target, session, hs.settings, hs.negotiatedVideoMime));
if (keyframeRequester != null) {
keyframeRequester.requestKeyframe();
@@ -202,8 +204,8 @@ public final class MultiCastCoordinator {
return session;
}
- private static void applyNegotiatedProtection(CastSession session, CastSettings local,
- CastSettings remote) {
+ private void applyNegotiatedProtection(CastSession session, CastSettings local,
+ CastSettings receiverAdvertisement) {
if (session == null) {
return;
}
@@ -215,7 +217,13 @@ public final class MultiCastCoordinator {
udp = ((QuicCronetCastTransport) transport).getUdpTransport();
}
if (udp != null) {
- CastTransportFactory.applyNegotiatedUdpProtection(udp, local, remote);
+ CastSettings remote = receiverAdvertisement;
+ StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection(
+ udp, local, remote, false);
+ Log.i(TAG, ProtectionNegotiationPrompts.summarize(appContext, negotiated));
+ if (negotiated.prompt != null) {
+ Log.i(TAG, ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, false));
+ }
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
index afbb9aa..a54a462 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -158,6 +158,7 @@ public class ScreenCastService extends Service implements
}
CastNotifications.createChannels(this);
com.foxx.androidcast.network.CastTransportFactory.init(this);
+ com.foxx.androidcast.diagnostics.CpuLoadSampler.warmUp();
projectionThread = new HandlerThread("CastProjection");
projectionThread.start();
projectionHandler = new Handler(projectionThread.getLooper());
@@ -411,6 +412,7 @@ public class ScreenCastService extends Service implements
}
private void startCaptureInternal(int resultCode, Intent data, CastSettings settings) throws IOException {
+ SenderPreviewService.stop(this);
MediaProjectionManager mgr =
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
projection = mgr.getMediaProjection(resultCode, data);
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
index 29dbb3c..dc2c83c 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
@@ -464,10 +464,14 @@ public class SenderActivity extends DrawerHostActivity {
if (resultCode == Activity.RESULT_OK && data != null) {
screenPreview.setProjectionResult(resultCode, data);
previewConsentDeclined = false;
+ findViewById(R.id.panel_preview).post(() -> {
+ refreshPreview();
+ findViewById(R.id.panel_preview).postDelayed(this::refreshPreview, 200);
+ });
} else {
previewConsentDeclined = true;
+ refreshPreview();
}
- refreshPreview();
return;
}
if (requestCode != REQUEST_MEDIA_PROJECTION) {
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java
index 181be5d..9dcfe22 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java
@@ -19,6 +19,7 @@ import android.widget.TextView;
import androidx.core.content.ContextCompat;
+import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.R;
import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator;
@@ -62,6 +63,10 @@ public final class SenderCapturePreview {
public void updateForMode(CastSettings.CaptureMode mode) {
activeMode = mode;
stopCamera();
+ if (CastActiveState.isSenderCasting()) {
+ showCastingPlaceholder();
+ return;
+ }
if (mode == CastSettings.CaptureMode.CAMERA) {
useFront = false;
startCameraPreview();
@@ -71,7 +76,7 @@ public final class SenderCapturePreview {
} else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) {
showCalibrationPreview();
} else if (screenPreview != null && screenPreview.needsProjection(mode)) {
- if (screenPreview.hasProjection()) {
+ if (screenPreview.hasConsent()) {
labelView.setVisibility(android.view.View.GONE);
textureView.setVisibility(android.view.View.VISIBLE);
screenPreview.attach(textureView, mode);
@@ -92,6 +97,16 @@ public final class SenderCapturePreview {
labelView.setText(labelView.getContext().getString(textRes));
}
+ public void showCastingPlaceholder() {
+ if (screenPreview != null) {
+ screenPreview.pausePreview();
+ }
+ stopCamera();
+ textureView.setVisibility(android.view.View.GONE);
+ labelView.setVisibility(android.view.View.VISIBLE);
+ labelView.setText(labelView.getContext().getString(R.string.preview_casting_active));
+ }
+
private void showCalibrationPreview() {
if (screenPreview != null) {
screenPreview.stop();
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderPreviewService.java b/app/src/main/java/com/foxx/androidcast/sender/SenderPreviewService.java
new file mode 100644
index 0000000..5fd649f
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderPreviewService.java
@@ -0,0 +1,64 @@
+package com.foxx.androidcast.sender;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.IBinder;
+
+import androidx.annotation.Nullable;
+
+import com.foxx.androidcast.CastNotifications;
+
+/**
+ * Foreground service so {@link SenderScreenPreview} can use MediaProjection on Android 10+.
+ * Stopped when screen cast {@link ScreenCastService} starts (that service holds projection FGS).
+ */
+public final class SenderPreviewService extends Service {
+ private static volatile boolean running;
+
+ public static boolean isRunning() {
+ return running;
+ }
+
+ public static void start(Context context) {
+ Intent intent = new Intent(context, SenderPreviewService.class);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ }
+
+ public static void stop(Context context) {
+ context.stopService(new Intent(context, SenderPreviewService.class));
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ CastNotifications.createChannels(this);
+ running = true;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ startForeground(CastNotifications.ID_SENDER + 100,
+ CastNotifications.sender(this, getString(com.foxx.androidcast.R.string.preview_screen_active),
+ SenderPreviewService.class));
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ running = false;
+ stopForeground(STOP_FOREGROUND_REMOVE);
+ super.onDestroy();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java
index ee970a4..0941f85 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java
@@ -16,6 +16,7 @@ import android.view.Surface;
import android.view.TextureView;
import android.view.WindowManager;
+import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastSettings;
/**
@@ -105,6 +106,9 @@ public final class SenderScreenPreview {
if (projection != null || resultData == null) {
return;
}
+ if (!CastActiveState.isSenderCasting() && !SenderPreviewService.isRunning()) {
+ SenderPreviewService.start(appContext);
+ }
MediaProjectionManager mgr =
appContext.getSystemService(MediaProjectionManager.class);
projection = mgr.getMediaProjection(resultCode, resultData);
@@ -133,8 +137,14 @@ public final class SenderScreenPreview {
DisplayMetrics metrics = new DisplayMetrics();
WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics);
- int w = Math.max(320, textureView.getWidth() > 0 ? textureView.getWidth() : metrics.widthPixels / 2);
- int h = Math.max(240, textureView.getHeight() > 0 ? textureView.getHeight() : metrics.heightPixels / 2);
+ int viewW = textureView.getWidth();
+ int viewH = textureView.getHeight();
+ if (viewW <= 0 || viewH <= 0) {
+ textureView.post(() -> ensureVirtualDisplay(textureView, mode));
+ return;
+ }
+ int w = Math.max(320, viewW);
+ int h = Math.max(240, viewH);
SurfaceTexture st = textureView.getSurfaceTexture();
if (st == null) {
return;
@@ -181,6 +191,9 @@ public final class SenderScreenPreview {
}
projection = null;
}
+ if (!CastActiveState.isSenderCasting()) {
+ SenderPreviewService.stop(appContext);
+ }
}
/** Pause preview surface only; keep MediaProjection consent for cast START. */
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index bfe5558..38a39d0 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -149,6 +149,8 @@