From 3ddf71064d7bb525d17bf8e4984c501035c41ea0 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 15 May 2026 17:46:56 +0200 Subject: [PATCH] snap --- app/src/main/AndroidManifest.xml | 2 + .../foxx/androidcast/ICastStatusCallback.aidl | 1 + .../androidcast/network/CastProtocol.java | 70 ++++ .../androidcast/network/TcpCastTransport.java | 69 +++- .../receiver/ReceiverActivity.java | 41 ++- .../receiver/ReceiverCastService.java | 317 +++++++++++++++--- .../receiver/ReceiverPlaybackActivity.java | 172 +++++++++- .../androidcast/receiver/ReceiverSession.java | 26 +- .../androidcast/receiver/VideoDecoder.java | 145 +++++--- .../androidcast/sender/ScreenCastService.java | 207 +++++++++--- .../androidcast/sender/SenderActivity.java | 44 ++- app/src/main/res/layout/activity_playback.xml | 8 +- app/src/main/res/layout/activity_receiver.xml | 7 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 14 + app/src/main/res/values/themes.xml | 5 + 16 files changed, 946 insertions(+), 183 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 99ec1b3..ce550b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,11 +48,13 @@ diff --git a/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl index 4528241..724f62f 100644 --- a/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl +++ b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl @@ -4,5 +4,6 @@ interface ICastStatusCallback { void onStatus(String status); void onAuthenticated(String senderName); void onStreamStarted(int width, int height); + void onVideoRendering(); void onDisconnected(); } diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java index 36a9663..f07e2c5 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -2,6 +2,8 @@ package com.foxx.androidcast.network; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.control.NetworkControlAdvice; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -22,6 +24,9 @@ public final class CastProtocol { public static final byte MSG_AUDIO_CONFIG = 8; public static final byte MSG_AUDIO_FRAME = 9; public static final byte MSG_CAST_SETTINGS = 10; + public static final byte MSG_NETWORK_STATS = 11; + public static final byte MSG_NETWORK_CONTROL = 12; + public static final byte MSG_AUDIO_CONSENT = 13; private CastProtocol() {} @@ -165,6 +170,64 @@ public final class CastProtocol { return local.getTransport().equals(remote.getTransport()); } + public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException { + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeLong(s.sequence); + dos.writeLong(s.timestampMs); + dos.writeInt(s.rttMs); + dos.writeFloat(s.lossRatio); + dos.writeInt(s.sendBitrateKbps); + dos.writeInt(s.recvBitrateKbps); + dos.writeInt(s.congestionLevel); + dos.writeInt(s.jitterMs); + dos.flush(); + return bos.toByteArray(); + } + + public static NetworkStatsSnapshot parseNetworkStats(byte[] payload) throws IOException { + DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload)); + NetworkStatsSnapshot s = new NetworkStatsSnapshot(); + s.sequence = dis.readLong(); + s.timestampMs = dis.readLong(); + s.rttMs = dis.readInt(); + s.lossRatio = dis.readFloat(); + s.sendBitrateKbps = dis.readInt(); + s.recvBitrateKbps = dis.readInt(); + s.congestionLevel = dis.readInt(); + s.jitterMs = dis.readInt(); + return s; + } + + public static byte[] networkControlPayload(NetworkControlAdvice advice) throws IOException { + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeInt(advice.suggestedBitrateKbps); + dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.TRANSPORT_TCP); + dos.writeByte(advice.switchTransport ? 1 : 0); + dos.writeInt(advice.congestionLevel); + dos.flush(); + return bos.toByteArray(); + } + + public static NetworkControl parseNetworkControl(byte[] payload) throws IOException { + DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload)); + NetworkControl c = new NetworkControl(); + c.suggestedBitrateKbps = dis.readInt(); + c.suggestedTransport = dis.readUTF(); + c.switchTransport = dis.readByte() != 0; + c.congestionLevel = dis.readInt(); + return c; + } + + public static byte[] audioConsentPayload(boolean accept) { + return new byte[]{(byte) (accept ? 1 : 0)}; + } + + public static boolean parseAudioConsent(byte[] payload) { + return payload != null && payload.length > 0 && payload[0] != 0; + } + private static void writeBytes(DataOutputStream dos, byte[] data) throws IOException { if (data == null) { dos.writeInt(0); @@ -260,4 +323,11 @@ public final class CastProtocol { this.data = data; } } + + public static final class NetworkControl { + public int suggestedBitrateKbps; + public String suggestedTransport; + public boolean switchTransport; + public int congestionLevel; + } } diff --git a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java index 358c06e..85b5c83 100644 --- a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java @@ -3,12 +3,19 @@ package com.foxx.androidcast.network; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketTimeoutException; -/** Reliable message transport over TCP. */ +/** Reliable message transport over TCP (serialized send/receive). */ public class TcpCastTransport implements CastTransport { + private static final int CONNECT_TIMEOUT_MS = 10_000; + + private final Object sendLock = new Object(); + private final Object receiveLock = new Object(); + private ServerSocket serverSocket; private Socket socket; private DataInputStream in; @@ -18,18 +25,23 @@ public class TcpCastTransport implements CastTransport { public void listen(int port) throws IOException { serverSocket = new ServerSocket(); serverSocket.setReuseAddress(true); - serverSocket.bind(new InetSocketAddress(port)); + serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port)); socket = serverSocket.accept(); socket.setTcpNoDelay(true); - openStreams(); - serverSocket.close(); + try { + serverSocket.close(); + } catch (IOException ignored) { + } serverSocket = null; + openStreams(); } @Override public void connect(String host, int port) throws IOException { - socket = new Socket(host, port); + InetAddress address = InetAddress.getByName(host); + socket = new Socket(); socket.setTcpNoDelay(true); + socket.connect(new InetSocketAddress(address, port), CONNECT_TIMEOUT_MS); openStreams(); } @@ -40,20 +52,35 @@ public class TcpCastTransport implements CastTransport { @Override public void send(byte type, byte[] payload) throws IOException { - CastProtocol.writeMessage(out, type, payload); + synchronized (sendLock) { + if (socket == null || socket.isClosed() || out == null) { + throw new IOException("Socket closed"); + } + CastProtocol.writeMessage(out, type, payload); + } } @Override public CastProtocol.Message receive(int timeoutMs) throws IOException { - if (timeoutMs > 0) { - socket.setSoTimeout(timeoutMs); - } - try { - return CastProtocol.readMessage(in); - } catch (java.net.SocketTimeoutException e) { - return null; - } catch (java.io.EOFException e) { - return null; + synchronized (receiveLock) { + if (socket == null || socket.isClosed() || in == null) { + return null; + } + if (timeoutMs > 0) { + socket.setSoTimeout(timeoutMs); + } + try { + return CastProtocol.readMessage(in); + } catch (SocketTimeoutException e) { + return null; + } catch (java.io.EOFException e) { + return null; + } catch (java.net.SocketException e) { + if ("Connection reset".equals(e.getMessage()) || "Socket closed".equals(e.getMessage())) { + return null; + } + throw e; + } } } @@ -64,6 +91,14 @@ public class TcpCastTransport implements CastTransport { @Override public void close() { + synchronized (sendLock) { + synchronized (receiveLock) { + closeSockets(); + } + } + } + + private void closeSockets() { try { if (socket != null) { socket.close(); @@ -71,12 +106,14 @@ public class TcpCastTransport implements CastTransport { } catch (Exception ignored) { } try { - if (serverSocket != null) { + if (serverSocket != null && !serverSocket.isClosed()) { serverSocket.close(); } } catch (Exception ignored) { } socket = null; serverSocket = null; + in = null; + out = null; } } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java index 23635fa..19135b6 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java @@ -9,7 +9,9 @@ import android.os.Bundle; import android.os.IBinder; import android.os.RemoteException; import android.text.TextUtils; +import android.view.WindowManager; import android.widget.Button; +import android.widget.CheckBox; import android.widget.EditText; import android.widget.TextView; @@ -23,12 +25,13 @@ import com.foxx.androidcast.PermissionHelper; import com.foxx.androidcast.R; import com.foxx.androidcast.SettingsUi; -/** Receiver setup only — fullscreen playback opens automatically when stream starts. */ +/** Receiver setup — listening runs in background; fullscreen playback opens when video stream starts. */ public class ReceiverActivity extends AppCompatActivity { private final CastSettings castSettings = new CastSettings(); private TextView statusText; private EditText pinInput; + private CheckBox allowAudioCheck; private ICastReceiverService receiverService; private boolean bound; @@ -49,6 +52,11 @@ public class ReceiverActivity extends AppCompatActivity { runOnUiThread(() -> statusText.setText(getString(R.string.playback_opened, width, height))); } + @Override + public void onVideoRendering() { + runOnUiThread(() -> statusText.setText(R.string.playback_rendering)); + } + @Override public void onDisconnected() { runOnUiThread(() -> statusText.setText(R.string.waiting_sender)); @@ -85,13 +93,17 @@ public class ReceiverActivity extends AppCompatActivity { statusText = findViewById(R.id.text_status); pinInput = findViewById(R.id.edit_pin); pinInput.setText(CastConfig.DEFAULT_PIN); + allowAudioCheck = findViewById(R.id.check_allow_audio); + allowAudioCheck.setChecked(ReceiverPrefs.isAllowAudio(this)); + allowAudioCheck.setOnCheckedChangeListener((btn, checked) -> + ReceiverPrefs.setAllowAudio(ReceiverActivity.this, checked)); + findViewById(R.id.btn_start_receiver).setOnClickListener(v -> startReceiverService()); } @Override protected void onStart() { super.onStart(); - startReceiverService(); bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE); } @@ -109,14 +121,38 @@ public class ReceiverActivity extends AppCompatActivity { super.onStop(); } + @Override + protected void onResume() { + super.onResume(); + updateKeepScreenOn(); + } + + @Override + protected void onPause() { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + super.onPause(); + } + + private void updateKeepScreenOn() { + if (bound && receiverService != null) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + private void startReceiverService() { String pin = pinInput.getText().toString().trim(); if (TextUtils.isEmpty(pin)) { pin = CastConfig.DEFAULT_PIN; } + boolean allowAudio = allowAudioCheck.isChecked(); + ReceiverPrefs.setAllowAudio(this, allowAudio); + Intent intent = new Intent(this, ReceiverCastService.class); intent.setAction(ReceiverCastService.ACTION_START); intent.putExtra(ReceiverCastService.EXTRA_PIN, pin); + intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio); intent.putExtra(CastSettings.EXTRA, castSettings); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(intent); @@ -124,5 +160,6 @@ public class ReceiverActivity extends AppCompatActivity { startService(intent); } statusText.setText(R.string.receiver_starting); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } } 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 83bd44e..8e9f63b 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -3,15 +3,16 @@ package com.foxx.androidcast.receiver; import android.app.NotificationManager; import android.app.Service; import android.content.Intent; +import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Looper; +import android.os.PowerManager; import android.os.RemoteCallbackList; import android.os.RemoteException; import android.util.Log; import android.view.Surface; -import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.ICastReceiverService; @@ -20,12 +21,22 @@ import com.foxx.androidcast.IntentExtras; import com.foxx.androidcast.R; import com.foxx.androidcast.discovery.DiscoveryManager; -/** Foreground receiver: discovery, TCP/UDP listen, decode A/V (survives rotation). */ +import java.util.Arrays; + +/** + * Foreground receiver: discovery, listen, decode. + * Playback UI opens only after {@link CastProtocol#MSG_STREAM_CONFIG} (sender is actually streaming). + */ public class ReceiverCastService extends Service { private static final String TAG = "ReceiverCastService"; public static final String ACTION_START = "com.foxx.androidcast.RECEIVER_START"; public static final String ACTION_STOP = "com.foxx.androidcast.STOP_RECV"; public static final String EXTRA_PIN = "pin"; + public static final String EXTRA_ALLOW_AUDIO = "allow_audio"; + + private enum Phase { + IDLE, LISTENING, CONNECTED, STREAMING + } private final RemoteCallbackList callbacks = new RemoteCallbackList<>(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); @@ -37,13 +48,29 @@ public class ReceiverCastService extends Service { private Surface attachedSurface; private String status = ""; private CastSettings settings = new CastSettings(); - private boolean started; + private Phase phase = Phase.IDLE; private int pendingWidth; private int pendingHeight; private byte[] pendingCsd0; private byte[] pendingCsd1; private boolean hasPendingConfig; + private volatile boolean reconfiguring; + private volatile boolean awaitingKeyframe = true; + private boolean playbackLaunched; + private int decoderWidth; + private int decoderHeight; + private boolean decoderActive; + + /** Set from receiver UI before each listen session. */ + private boolean allowIncomingAudio = true; + /** null until first audio config this session; then mirrors user preference. */ + private Boolean audioAccepted; + private int pendingAudioRate; + private int pendingAudioChannels; + private byte[] pendingAudioCsd; + private PowerManager.WakeLock wakeLock; + private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN; private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() { @Override @@ -97,30 +124,43 @@ public class ReceiverCastService extends Service { if (intent != null && ACTION_START.equals(intent.getAction())) { String pin = intent.getStringExtra(EXTRA_PIN); if (pin == null || pin.isEmpty()) { - pin = CastConfig.DEFAULT_PIN; + pin = com.foxx.androidcast.CastConfig.DEFAULT_PIN; } CastSettings fromIntent = IntentExtras.getCastSettings(intent); if (fromIntent != null) { settings = fromIntent; } - startReceiving(pin); + allowIncomingAudio = intent.getBooleanExtra(EXTRA_ALLOW_AUDIO, + ReceiverPrefs.isAllowAudio(this)); + activePin = pin; + restartListening(pin); + } else if (intent == null && phase == Phase.IDLE) { + allowIncomingAudio = ReceiverPrefs.isAllowAudio(this); + restartListening(activePin); } return START_STICKY; } - private void startReceiving(String pin) { - if (started) { - return; - } - started = true; + private void restartListening(String pin) { + stopSessionOnly(); + closePlaybackUi(); + resetStreamState(); + phase = Phase.LISTENING; + acquireWakeLock(); startForeground(CastNotifications.ID_RECEIVER, CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class)); + if (videoDecoder != null) { + videoDecoder.release(); + } + if (audioDecoder != null) { + audioDecoder.release(); + } videoDecoder = new VideoDecoder(); audioDecoder = new AudioDecoder(); discovery = new DiscoveryManager(this); - discovery.startAnnouncing(android.os.Build.MODEL, CastConfig.CAST_PORT, settings.getTransport()); + discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT, settings.getTransport()); session = new ReceiverSession(pin, settings, new ReceiverSession.Listener() { @Override @@ -130,8 +170,7 @@ public class ReceiverCastService extends Service { @Override public void onAuthenticated(String senderName, CastSettings remoteSettings) { - updateStatus(getString(R.string.casting_from, senderName)); - broadcastAuthenticated(senderName); + mainHandler.post(() -> onSenderConnected(senderName)); } @Override @@ -141,49 +180,163 @@ public class ReceiverCastService extends Service { @Override public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) { - if (videoDecoder != null) { - videoDecoder.queueFrame(ptsUs, keyFrame, data); + if (reconfiguring) { + return; + } + if (awaitingKeyframe && !keyFrame) { + return; + } + if (keyFrame) { + awaitingKeyframe = false; } - } - - @Override - public void onAudioConfig(int sampleRate, int channels, byte[] csd0) { mainHandler.post(() -> { - try { - if (audioDecoder != null) { - audioDecoder.configure(sampleRate, channels, csd0); - } - } catch (Exception e) { - updateStatus("Audio: " + e.getMessage()); + if (videoDecoder != null && !reconfiguring) { + videoDecoder.queueFrame(ptsUs, keyFrame, data); } }); } + @Override + public void onAudioConfig(int sampleRate, int channels, byte[] csd0) { + // Network I/O must stay on the session reader thread (StrictMode). + final boolean accept; + synchronized (ReceiverCastService.this) { + if (audioAccepted == null) { + accept = allowIncomingAudio; + audioAccepted = accept; + if (session != null) { + session.sendAudioConsent(accept); + } + } else { + accept = Boolean.TRUE.equals(audioAccepted); + } + } + final boolean accepted = accept; + mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted)); + } + @Override public void onAudioFrame(long ptsUs, byte[] data) { - if (audioDecoder != null) { + if (Boolean.TRUE.equals(audioAccepted) && audioDecoder != null) { audioDecoder.queueFrame(ptsUs, data); } } @Override public void onDisconnected() { - broadcastDisconnected(); - updateStatus(getString(R.string.waiting_sender)); + mainHandler.post(ReceiverCastService.this::onSenderDisconnected); + } + + @Override + public void onNetworkAdviceApplied() { } }); session.start(); updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase())); + Log.i(TAG, "Listening PIN=" + pin + " audio=" + allowIncomingAudio); + } + + private void onSenderConnected(String senderName) { + phase = Phase.CONNECTED; + audioAccepted = null; + playbackLaunched = false; + updateStatus(getString(R.string.casting_from, senderName)); + broadcastAuthenticated(senderName); + Log.i(TAG, "Sender connected: " + senderName); + } + + private void onSenderDisconnected() { + phase = Phase.LISTENING; + audioAccepted = null; + hasPendingConfig = false; + awaitingKeyframe = true; + detachSurfaceOnly(); + broadcastDisconnected(); + updateStatus(getString(R.string.waiting_sender)); + Log.i(TAG, "Sender disconnected"); + } + + private void resetStreamState() { + hasPendingConfig = false; + playbackLaunched = false; + awaitingKeyframe = true; + pendingWidth = 0; + pendingHeight = 0; + pendingCsd0 = null; + pendingCsd1 = null; + resetDecoderState(); + } + + private void resetDecoderState() { + decoderActive = false; + decoderWidth = 0; + decoderHeight = 0; + } + + private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) { + pendingAudioRate = sampleRate; + pendingAudioChannels = channels; + pendingAudioCsd = csd0; + if (accepted) { + configureAudioIfAccepted(); + updateStatus(getString(R.string.audio_playing)); + } else { + if (audioDecoder != null) { + audioDecoder.release(); + audioDecoder = new AudioDecoder(); + } + updateStatus(getString(R.string.audio_skipped)); + } + Log.i(TAG, "Audio consent: " + accepted); + } + + private void configureAudioIfAccepted() { + if (!Boolean.TRUE.equals(audioAccepted) || audioDecoder == null) { + return; + } + try { + audioDecoder.configure(pendingAudioRate, pendingAudioChannels, pendingAudioCsd); + } catch (Exception e) { + Log.e(TAG, "Audio configure failed", e); + updateStatus("Audio: " + e.getMessage()); + } } private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) { - pendingWidth = width; - pendingHeight = height; + com.foxx.androidcast.media.StreamDimensionResolver.Size size = + com.foxx.androidcast.media.StreamDimensionResolver.resolve(width, height, csd0); + if (size.width <= 0 || size.height <= 0) { + Log.w(TAG, "Ignoring invalid stream config " + width + "x" + height); + return; + } + boolean same = hasPendingConfig + && pendingWidth == size.width + && pendingHeight == size.height + && Arrays.equals(pendingCsd0, csd0) + && Arrays.equals(pendingCsd1, csd1); + if (same && decoderActive) { + Log.d(TAG, "Stream config unchanged " + size.width + "x" + size.height); + return; + } + phase = Phase.STREAMING; + reconfiguring = true; + awaitingKeyframe = true; + pendingWidth = size.width; + pendingHeight = size.height; pendingCsd0 = csd0; pendingCsd1 = csd1; hasPendingConfig = true; - launchPlaybackActivity(width, height); + decoderActive = false; + mainHandler.postDelayed(() -> awaitingKeyframe = false, 2000); + if (!playbackLaunched) { + playbackLaunched = true; + launchPlaybackActivity(pendingWidth, pendingHeight); + } else { + notifyPlaybackDimensions(pendingWidth, pendingHeight); + } tryConfigureDecoder(); + reconfiguring = false; + Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight); } private void launchPlaybackActivity(int width, int height) { @@ -194,36 +347,101 @@ public class ReceiverCastService extends Service { startActivity(intent); } + private void notifyPlaybackDimensions(int width, int height) { + broadcastStreamStarted(width, height); + } + + private void closePlaybackUi() { + if (!playbackLaunched) { + return; + } + Intent intent = new Intent(this, ReceiverPlaybackActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.putExtra(ReceiverPlaybackActivity.EXTRA_FINISH, true); + startActivity(intent); + playbackLaunched = false; + } + + private void stopSessionOnly() { + if (discovery != null) { + discovery.stop(); + discovery = null; + } + if (session != null) { + session.stop(); + session = null; + } + } + + private void acquireWakeLock() { + if (wakeLock != null && wakeLock.isHeld()) { + return; + } + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AndroidCast:Receive"); + wakeLock.acquire(4 * 60 * 60 * 1000L); + } + + private void releaseWakeLock() { + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + wakeLock = null; + } + private void tryConfigureDecoder() { if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) { + Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig + + " surface=" + (attachedSurface != null && attachedSurface.isValid())); + return; + } + if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) { + Log.d(TAG, "Decoder already active at " + decoderWidth + "x" + decoderHeight); return; } try { - if (videoDecoder == null) { - videoDecoder = new VideoDecoder(); + if (videoDecoder != null) { + videoDecoder.release(); } - videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface); + videoDecoder = new VideoDecoder(); + videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface, + this::onFirstFrameRendered); + decoderActive = true; + decoderWidth = pendingWidth; + decoderHeight = pendingHeight; updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight)); broadcastStreamStarted(pendingWidth, pendingHeight); - hasPendingConfig = false; + Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight); } catch (Exception e) { + decoderActive = false; Log.e(TAG, "Video configure failed", e); updateStatus("Video: " + e.getMessage()); } } + private void onFirstFrameRendered() { + mainHandler.post(() -> { + Log.i(TAG, "First frame on screen"); + broadcastVideoRendering(); + }); + } + private void attachSurface(Surface surface) { + if (surface != null && surface.equals(attachedSurface) && decoderActive) { + Log.d(TAG, "Surface unchanged, skip reconfigure"); + return; + } attachedSurface = surface; tryConfigureDecoder(); } private void detachSurfaceOnly() { attachedSurface = null; + resetDecoderState(); if (videoDecoder != null) { videoDecoder.release(); videoDecoder = new VideoDecoder(); } - hasPendingConfig = pendingWidth > 0 && pendingHeight > 0; } private void updateStatus(String s) { @@ -267,6 +485,17 @@ public class ReceiverCastService extends Service { callbacks.finishBroadcast(); } + private void broadcastVideoRendering() { + int n = callbacks.beginBroadcast(); + for (int i = 0; i < n; i++) { + try { + callbacks.getBroadcastItem(i).onVideoRendering(); + } catch (RemoteException ignored) { + } + } + callbacks.finishBroadcast(); + } + private void broadcastDisconnected() { int n = callbacks.beginBroadcast(); for (int i = 0; i < n; i++) { @@ -279,16 +508,12 @@ public class ReceiverCastService extends Service { } private void releaseAll() { - started = false; - hasPendingConfig = false; - if (discovery != null) { - discovery.stop(); - discovery = null; - } - if (session != null) { - session.stop(); - session = null; - } + phase = Phase.IDLE; + resetStreamState(); + audioAccepted = null; + stopSessionOnly(); + closePlaybackUi(); + releaseWakeLock(); if (videoDecoder != null) { videoDecoder.release(); videoDecoder = null; 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 a223ea4..8a1fba2 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -4,7 +4,6 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; -import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.os.RemoteException; @@ -12,32 +11,86 @@ import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.View; import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.ImageView; +import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; import com.foxx.androidcast.ICastReceiverService; +import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.R; import com.foxx.androidcast.ui.AspectRatioFrameLayout; -/** Fullscreen video-only playback (no controls overlay). */ +/** Fullscreen video playback — robot overlay while waiting or after stream loss. */ public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback { public static final String EXTRA_WIDTH = "video_width"; public static final String EXTRA_HEIGHT = "video_height"; + public static final String EXTRA_FINISH = "finish_playback"; private AspectRatioFrameLayout aspectContainer; private SurfaceView surfaceView; + private View awaitingOverlay; + private ImageView robotView; + private TextView awaitingMessage; private SurfaceHolder surfaceHolder; private int videoWidth; private int videoHeight; + private boolean streamRendering; + private boolean surfaceSentToService; + private Animation robotDance; private ICastReceiverService receiverService; private boolean bound; + private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() { + @Override + public void onStatus(String status) { + runOnUiThread(() -> { + if (status != null && status.toLowerCase().contains("waiting")) { + showAwaitingOverlay(R.string.playback_awaiting_stream); + } + }); + } + + @Override + public void onAuthenticated(String senderName) { + runOnUiThread(() -> showAwaitingOverlay(R.string.playback_awaiting_stream)); + } + + @Override + public void onStreamStarted(int width, int height) { + runOnUiThread(() -> updateVideoSize(width, height)); + } + + @Override + public void onVideoRendering() { + runOnUiThread(() -> { + streamRendering = true; + hideAwaitingOverlay(); + }); + } + + @Override + public void onDisconnected() { + runOnUiThread(() -> onStreamLost()); + } + }; + private final ServiceConnection connection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { receiverService = ICastReceiverService.Stub.asInterface(service); bound = true; + try { + receiverService.registerCallback(statusCallback); + String status = receiverService.getStatus(); + if (status != null && status.toLowerCase().contains("waiting")) { + showAwaitingOverlay(R.string.playback_awaiting_stream); + } + } catch (RemoteException ignored) { + } attachSurfaceIfReady(); } @@ -51,27 +104,65 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (handleCommandIntent(getIntent())) { + return; + } setContentView(R.layout.activity_playback); applyFullscreen(); - videoWidth = getIntent().getIntExtra(EXTRA_WIDTH, 0); - videoHeight = getIntent().getIntExtra(EXTRA_HEIGHT, 0); + readSizeFromIntent(getIntent()); aspectContainer = findViewById(R.id.aspect_container); surfaceView = findViewById(R.id.surface_playback); + awaitingOverlay = findViewById(R.id.overlay_awaiting); + robotView = findViewById(R.id.image_robot); + awaitingMessage = findViewById(R.id.text_awaiting_message); surfaceHolder = surfaceView.getHolder(); surfaceHolder.addCallback(this); + robotDance = AnimationUtils.loadAnimation(this, R.anim.robot_dance); applyAspectRatio(); + showAwaitingOverlay(R.string.playback_awaiting_stream); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); + if (handleCommandIntent(intent)) { + return; + } + readSizeFromIntent(intent); + updateVideoSize(videoWidth, videoHeight); + if (!streamRendering) { + showAwaitingOverlay(R.string.playback_awaiting_stream); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE) { + startRobotAnimation(); + } + } + + @Override + protected void onPause() { + stopRobotAnimation(); + super.onPause(); + } + + private boolean handleCommandIntent(Intent intent) { + if (intent != null && intent.getBooleanExtra(EXTRA_FINISH, false)) { + finish(); + return true; + } + return false; + } + + private void readSizeFromIntent(Intent intent) { videoWidth = intent.getIntExtra(EXTRA_WIDTH, 0); videoHeight = intent.getIntExtra(EXTRA_HEIGHT, 0); - applyAspectRatio(); - attachSurfaceIfReady(); } @Override @@ -82,6 +173,12 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa @Override protected void onStop() { + if (bound && receiverService != null) { + try { + receiverService.unregisterCallback(statusCallback); + } catch (RemoteException ignored) { + } + } detachSurface(); if (bound) { unbindService(connection); @@ -91,6 +188,58 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa super.onStop(); } + private void onStreamLost() { + streamRendering = false; + showAwaitingOverlay(R.string.playback_stream_lost); + } + + private void showAwaitingOverlay(int messageResId) { + streamRendering = false; + if (awaitingOverlay == null) { + return; + } + awaitingOverlay.setVisibility(View.VISIBLE); + if (awaitingMessage != null) { + awaitingMessage.setText(messageResId); + } + startRobotAnimation(); + } + + private void hideAwaitingOverlay() { + if (awaitingOverlay == null) { + return; + } + awaitingOverlay.setVisibility(View.GONE); + stopRobotAnimation(); + } + + private void startRobotAnimation() { + if (robotView == null || robotDance == null) { + return; + } + robotView.clearAnimation(); + robotView.startAnimation(robotDance); + } + + private void stopRobotAnimation() { + if (robotView != null) { + robotView.clearAnimation(); + } + } + + private void updateVideoSize(int width, int height) { + if (width <= 0 || height <= 0) { + return; + } + videoWidth = width; + videoHeight = height; + applyAspectRatio(); + if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) { + surfaceHolder.setFixedSize(videoWidth, videoHeight); + attachSurfaceIfReady(); + } + } + private void applyFullscreen() { getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().getDecorView().setSystemUiVisibility( @@ -121,16 +270,19 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - attachSurfaceIfReady(); + if (videoWidth > 0 && videoHeight > 0) { + holder.setFixedSize(videoWidth, videoHeight); + } } @Override public void surfaceDestroyed(SurfaceHolder holder) { + surfaceSentToService = false; detachSurface(); } private void attachSurfaceIfReady() { - if (!bound || receiverService == null || surfaceHolder.getSurface() == null) { + if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) { return; } if (!surfaceHolder.getSurface().isValid()) { @@ -138,18 +290,20 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa } try { receiverService.attachVideoSurface(surfaceHolder.getSurface()); + surfaceSentToService = true; } catch (RemoteException ignored) { } } private void detachSurface() { - if (receiverService == null) { + if (!surfaceSentToService || receiverService == null) { return; } try { receiverService.detachVideoSurface(); } catch (RemoteException ignored) { } + surfaceSentToService = false; } @Override 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 27ab02e..1a1f266 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -7,6 +7,7 @@ import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastSession; import com.foxx.androidcast.network.CastTransportFactory; +import com.foxx.androidcast.network.control.NetworkFeedbackManager; import java.io.IOException; import java.util.concurrent.ExecutorService; @@ -31,6 +32,8 @@ public class ReceiverSession { void onAudioFrame(long ptsUs, byte[] data); void onDisconnected(); + + void onNetworkAdviceApplied(); } private final String pin; @@ -40,6 +43,7 @@ public class ReceiverSession { private final AtomicBoolean running = new AtomicBoolean(false); private CastSession session; + private NetworkFeedbackManager networkFeedback; public ReceiverSession(String pin, CastSettings localSettings, Listener listener) { this.pin = pin; @@ -62,10 +66,22 @@ public class ReceiverSession { } } + public void sendAudioConsent(boolean accept) { + if (session == null) { + return; + } + try { + session.send(CastProtocol.MSG_AUDIO_CONSENT, CastProtocol.audioConsentPayload(accept)); + } catch (IOException e) { + Log.w(TAG, "Failed to send audio consent", e); + } + } + private void runServer() { while (running.get()) { try { session = new CastSession(CastTransportFactory.create(localSettings)); + networkFeedback = new NetworkFeedbackManager(session, false); session.listen(CastConfig.CAST_PORT); postStatus("Listening (" + session.getTransport().getModeLabel() + ") port " + CastConfig.CAST_PORT); @@ -88,6 +104,7 @@ public class ReceiverSession { session.close(); session = null; } + networkFeedback = null; listener.onDisconnected(); } if (running.get()) { @@ -98,9 +115,12 @@ public class ReceiverSession { private void readStream() throws IOException { while (running.get()) { + if (networkFeedback != null) { + networkFeedback.tick(); + } CastProtocol.Message msg; try { - msg = session.receive(5_000); + msg = session.receive(500); } catch (java.io.EOFException e) { Log.i(TAG, "Sender closed connection"); return; @@ -108,6 +128,10 @@ public class ReceiverSession { if (msg == null) { continue; } + if (networkFeedback != null && networkFeedback.handleMessage(msg)) { + listener.onNetworkAdviceApplied(); + continue; + } switch (msg.type) { case CastProtocol.MSG_STREAM_CONFIG: CastProtocol.StreamConfig vcfg = CastProtocol.parseStreamConfig(msg.payload); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java index 276da45..0451380 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java @@ -12,96 +12,157 @@ import java.nio.ByteBuffer; import java.util.ArrayDeque; import java.util.Queue; -/** H.264 decoder feeding a Surface for playback. */ +/** Thread-safe H.264 decoder feeding a Surface. */ public class VideoDecoder { private static final String TAG = "VideoDecoder"; private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC; + public interface Listener { + void onFirstFrameRendered(); + } + + private final Object lock = new Object(); private MediaCodec codec; private final Queue pending = new ArrayDeque<>(); private boolean configured; - private int width; - private int height; + private boolean released; + private Listener listener; + private boolean firstFrameNotified; - public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface) throws IOException { - this.width = width; - this.height = height; - MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height); - format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height); - if (csd0 != null && csd0.length > 0) { - format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0))); + public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface, Listener listener) + throws IOException { + synchronized (lock) { + releaseLocked(); + released = false; + this.listener = listener; + firstFrameNotified = false; + MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height); + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height * 2); + if (csd0 != null && csd0.length > 0) { + format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0))); + } + if (csd1 != null && csd1.length > 0) { + format.setByteBuffer("csd-1", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd1))); + } + codec = MediaCodec.createDecoderByType(MIME); + codec.configure(format, surface, null, 0); + codec.start(); + configured = true; + Log.i(TAG, "Configured " + width + "x" + height); + drainPendingLocked(); } - if (csd1 != null && csd1.length > 0) { - format.setByteBuffer("csd-1", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd1))); - } - codec = MediaCodec.createDecoderByType(MIME); - codec.configure(format, surface, null, 0); - codec.start(); - configured = true; - drainPending(); } - public synchronized void queueFrame(long ptsUs, boolean keyFrame, byte[] data) { - byte[] annexB = H264Bitstream.toAnnexB(data); - if (!configured) { - pending.add(new PendingFrame(ptsUs, keyFrame, annexB)); - return; + public void queueFrame(long ptsUs, boolean keyFrame, byte[] data) { + synchronized (lock) { + if (released || data == null || data.length == 0) { + return; + } + byte[] annexB = H264Bitstream.toAnnexB(data); + if (!configured || codec == null) { + pending.add(new PendingFrame(ptsUs, keyFrame, annexB)); + return; + } + decodeFrameLocked(ptsUs, keyFrame, annexB); } - decodeFrame(ptsUs, keyFrame, annexB); } public void release() { + synchronized (lock) { + releaseLocked(); + } + } + + private void releaseLocked() { configured = false; + released = true; + listener = null; + firstFrameNotified = false; pending.clear(); - if (codec != null) { + MediaCodec c = codec; + codec = null; + if (c != null) { try { - codec.stop(); + c.stop(); + } catch (Exception ignored) { + } + try { + c.release(); } catch (Exception ignored) { } - codec.release(); - codec = null; } } - private void drainPending() { + private void drainPendingLocked() { PendingFrame frame; while ((frame = pending.poll()) != null) { - decodeFrame(frame.ptsUs, frame.keyFrame, frame.data); + decodeFrameLocked(frame.ptsUs, frame.keyFrame, frame.data); } } - private void decodeFrame(long ptsUs, boolean keyFrame, byte[] data) { - if (codec == null || data == null || data.length == 0) { + private void decodeFrameLocked(long ptsUs, boolean keyFrame, byte[] data) { + MediaCodec c = codec; + if (c == null || !configured) { return; } try { - int inIndex = codec.dequeueInputBuffer(10_000); + int inIndex = c.dequeueInputBuffer(10_000); if (inIndex < 0) { return; } - ByteBuffer buffer = codec.getInputBuffer(inIndex); + ByteBuffer buffer = c.getInputBuffer(inIndex); if (buffer == null) { return; } buffer.clear(); buffer.put(data); int flags = keyFrame ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0; - codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, flags); - drainOutput(); + c.queueInputBuffer(inIndex, 0, data.length, ptsUs, flags); + drainOutputLocked(c); + } catch (IllegalStateException e) { + Log.w(TAG, "Decode skipped (codec state): " + e.getMessage()); } catch (Exception e) { Log.e(TAG, "Decode error", e); } } - private void drainOutput() { - MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); - int outIndex = codec.dequeueOutputBuffer(info, 0); - while (outIndex >= 0) { - codec.releaseOutputBuffer(outIndex, true); - outIndex = codec.dequeueOutputBuffer(info, 0); + private void drainOutputLocked(MediaCodec c) { + try { + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + for (;;) { + int outIndex = c.dequeueOutputBuffer(info, 10_000); + if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + break; + } + if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + Log.i(TAG, "Output format: " + c.getOutputFormat()); + continue; + } + if (outIndex < 0) { + continue; + } + boolean render = info.size > 0 + && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0; + c.releaseOutputBuffer(outIndex, render); + if (render) { + notifyFirstFrameLocked(); + } + } + } catch (IllegalStateException e) { + Log.w(TAG, "Drain skipped (codec state): " + e.getMessage()); } } + private void notifyFirstFrameLocked() { + if (firstFrameNotified || listener == null) { + return; + } + firstFrameNotified = true; + Listener cb = listener; + Log.i(TAG, "First frame rendered"); + cb.onFirstFrameRendered(); + } + private static final class PendingFrame { final long ptsUs; final boolean keyFrame; 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 1d54f06..9f6c319 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -5,6 +5,7 @@ import android.content.Intent; import android.content.pm.ServiceInfo; import android.hardware.display.DisplayManager; import android.hardware.display.VirtualDisplay; +import android.view.Display; import android.media.projection.MediaProjection; import android.media.projection.MediaProjectionManager; import android.os.Build; @@ -29,6 +30,7 @@ import com.foxx.androidcast.R; import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastSession; import com.foxx.androidcast.network.CastTransportFactory; +import com.foxx.androidcast.network.control.NetworkFeedbackManager; import java.io.IOException; import java.util.concurrent.CountDownLatch; @@ -39,8 +41,8 @@ import java.util.concurrent.atomic.AtomicReference; /** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */ public class ScreenCastService extends Service { private static final String TAG = "ScreenCastService"; - private static final int CONNECT_RETRIES = 5; - private static final long CONNECT_RETRY_MS = 800; + private static final int CONNECT_RETRIES = 20; + private static final long CONNECT_RETRY_MS = 1000; public static final String EXTRA_RESULT_CODE = "result_code"; public static final String EXTRA_RESULT_DATA = "result_data"; @@ -61,10 +63,18 @@ public class ScreenCastService extends Service { private AudioEncoder audioEncoder; private AudioCapture audioCapture; private CastSession session; + private NetworkFeedbackManager networkFeedback; private PowerManager.WakeLock wakeLock; private Thread ioThread; private String status = ""; + private CastSettings activeSettings; + private int captureWidth; + private int captureHeight; + private int captureDensity; + private DisplayManager displayManager; + private DisplayManager.DisplayListener displayListener; + private final ICastSenderService.Stub binder = new ICastSenderService.Stub() { @Override public void registerCallback(ICastStatusCallback callback) { @@ -144,17 +154,47 @@ public class ScreenCastService extends Service { try { updateStatus(getString(R.string.connecting_to, host)); session = new CastSession(CastTransportFactory.create(settings)); + updateStatus(getString(R.string.waiting_receiver, host, port)); connectWithRetry(host, port); updateStatus(getString(R.string.authenticating)); session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings); + networkFeedback = new NetworkFeedbackManager(session, true); casting.set(true); + activeSettings = settings; startCaptureOnMainThread(resultCode, new Intent(data), settings); while (!stopping.get()) { - Thread.sleep(500); + try { + if (networkFeedback != null) { + networkFeedback.tick(); + CastProtocol.Message msg = session.receive(100); + if (msg != null) { + if (networkFeedback.handleMessage(msg)) { + networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice()); + } else if (msg.type == CastProtocol.MSG_AUDIO_CONSENT) { + handleAudioConsent(CastProtocol.parseAudioConsent(msg.payload)); + } + } + } + Thread.sleep(400); + } catch (java.net.SocketException e) { + if (!stopping.get()) { + Log.i(TAG, "Connection lost: " + e.getMessage()); + stopping.set(true); + } + break; + } catch (IOException e) { + if (!stopping.get()) { + Log.i(TAG, "Connection lost: " + e.getMessage()); + stopping.set(true); + } + break; + } } } catch (Exception e) { - Log.e(TAG, "Cast failed", e); - failAndStop(e.getMessage() != null ? e.getMessage() : e.toString()); + if (!stopping.get()) { + Log.e(TAG, "Cast failed", e); + failAndStop(e.getMessage() != null ? e.getMessage() : e.toString()); + } } finally { releaseAll(); stopForeground(STOP_FOREGROUND_REMOVE); @@ -228,38 +268,8 @@ public class ScreenCastService extends Service { WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); CastResolution.Size size = CastResolution.compute(metrics, settings); - - videoEncoder = new VideoEncoder(); - VideoEncoder.Callback videoCb = new VideoEncoder.Callback() { - private boolean configSent; - - @Override - public void onConfig(int w, int h, byte[] csd0, byte[] csd1) { - if (configSent || stopping.get()) { - return; - } - configSent = true; - sendSafe(CastProtocol.MSG_STREAM_CONFIG, - () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1)); - } - - @Override - public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { - sendSafe(CastProtocol.MSG_VIDEO_FRAME, - () -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData)); - } - }; - - Surface surface = videoEncoder.prepare(size.width, size.height, settings, videoCb); - virtualDisplay = projection.createVirtualDisplay( - "AndroidCast", - size.width, - size.height, - size.densityDpi, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, - surface, - null, - null); + setupVideoPipeline(size, settings); + registerDisplayListener(); if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { audioEncoder = new AudioEncoder(); @@ -290,9 +300,117 @@ public class ScreenCastService extends Service { }); } - videoEncoder.requestKeyframe(); String mode = session.getTransport().getModeLabel(); - updateStatus(getString(R.string.casting_active, mode, size.width, size.height)); + updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight)); + } + + private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException { + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + if (videoEncoder != null) { + videoEncoder.stop(); + videoEncoder = null; + } + + captureWidth = size.width; + captureHeight = size.height; + captureDensity = size.densityDpi; + + videoEncoder = new VideoEncoder(); + VideoEncoder.Callback videoCb = 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) { + sendSafe(CastProtocol.MSG_VIDEO_FRAME, + () -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData)); + } + }; + + Surface surface = videoEncoder.prepare(captureWidth, captureHeight, settings, videoCb); + virtualDisplay = projection.createVirtualDisplay( + "AndroidCast", + captureWidth, + captureHeight, + captureDensity, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, + null, + null); + videoEncoder.requestKeyframe(); + Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight); + } + + private void registerDisplayListener() { + if (displayManager != null) { + return; + } + displayManager = (DisplayManager) getSystemService(DISPLAY_SERVICE); + displayListener = new DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) {} + + @Override + public void onDisplayRemoved(int displayId) {} + + @Override + public void onDisplayChanged(int displayId) { + if (displayId == Display.DEFAULT_DISPLAY) { + mainHandler.post(ScreenCastService.this::maybeReconfigureForRotation); + } + } + }; + displayManager.registerDisplayListener(displayListener, mainHandler); + } + + private void unregisterDisplayListener() { + if (displayManager != null && displayListener != null) { + displayManager.unregisterDisplayListener(displayListener); + } + displayManager = null; + displayListener = null; + } + + private void maybeReconfigureForRotation() { + if (!casting.get() || projection == null || activeSettings == null || stopping.get()) { + return; + } + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(metrics); + CastResolution.Size size = CastResolution.compute(metrics, activeSettings); + if (size.width == captureWidth && size.height == captureHeight) { + return; + } + try { + setupVideoPipeline(size, activeSettings); + updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight)); + } catch (IOException e) { + Log.e(TAG, "Rotation reconfigure failed", e); + } + } + + private void handleAudioConsent(boolean accept) { + if (!accept) { + if (audioCapture != null) { + audioCapture.stop(); + audioCapture = null; + } + if (audioEncoder != null) { + audioEncoder.stop(); + audioEncoder = null; + } + Log.i(TAG, "Receiver declined audio"); + } } private void send(byte type, byte[] payloadBytes) throws IOException { @@ -303,11 +421,16 @@ public class ScreenCastService extends Service { } private void sendSafe(byte type, PayloadBuilder builder) { + if (stopping.get()) { + return; + } try { send(type, builder.build()); } catch (IOException e) { - Log.e(TAG, "Send failed", e); - stopping.set(true); + if (!stopping.get()) { + Log.w(TAG, "Send failed: " + e.getMessage()); + stopping.set(true); + } } } @@ -331,6 +454,8 @@ public class ScreenCastService extends Service { private void releaseAll() { stopping.set(true); casting.set(false); + unregisterDisplayListener(); + networkFeedback = null; if (virtualDisplay != null) { virtualDisplay.release(); virtualDisplay = null; 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 f4f5115..8719e6f 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -7,7 +7,7 @@ import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.text.TextUtils; -import android.widget.ArrayAdapter; +import android.view.WindowManager; import android.widget.Button; import android.widget.EditText; import android.widget.ListView; @@ -24,17 +24,12 @@ import com.foxx.androidcast.R; import com.foxx.androidcast.SettingsUi; import com.foxx.androidcast.discovery.DiscoveryManager; -import java.util.ArrayList; -import java.util.List; - public class SenderActivity extends AppCompatActivity { private static final int REQUEST_MEDIA_PROJECTION = 2001; private final CastSettings castSettings = new CastSettings(); private DiscoveryManager discovery; - private List devices = new ArrayList<>(); - private ArrayAdapter listAdapter; - private DiscoveryManager.DiscoveredDevice selected; + private DeviceListAdapter deviceAdapter; private EditText pinInput; private TextView statusText; @@ -50,29 +45,25 @@ public class SenderActivity extends AppCompatActivity { pinInput.setText(CastConfig.DEFAULT_PIN); statusText = findViewById(R.id.text_status); ListView listView = findViewById(R.id.list_devices); - listAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, new ArrayList<>()); - listView.setAdapter(listAdapter); + listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); + deviceAdapter = new DeviceListAdapter(this); + listView.setAdapter(deviceAdapter); listView.setOnItemClickListener((parent, view, position, id) -> { - if (position < devices.size()) { - selected = devices.get(position); - if (!castSettings.getTransport().equals(selected.transport)) { - Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show(); - } - statusText.setText("Selected: " + selected.name + " (" + selected.host + ", " - + selected.transport.toUpperCase() + ")"); + deviceAdapter.setSelectedPosition(position); + DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); + if (selected == null) { + return; } + if (!castSettings.getTransport().equals(selected.transport)) { + Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show(); + } + statusText.setText(getString(R.string.device_selected, selected.name, selected.host, + selected.transport.toUpperCase())); }); discovery = new DiscoveryManager(this); discovery.setListener(deviceList -> { - devices = deviceList; - List labels = new ArrayList<>(); - for (DiscoveryManager.DiscoveredDevice d : deviceList) { - labels.add(d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]"); - } - listAdapter.clear(); - listAdapter.addAll(labels); - listAdapter.notifyDataSetChanged(); + deviceAdapter.setDevices(deviceList); statusText.setText(deviceList.isEmpty() ? getString(R.string.searching) : getString(R.string.found_receivers, deviceList.size())); @@ -105,6 +96,7 @@ public class SenderActivity extends AppCompatActivity { } private void startCastFlow() { + DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); if (selected == null) { Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show(); return; @@ -131,6 +123,7 @@ public class SenderActivity extends AppCompatActivity { if (requestCode != REQUEST_MEDIA_PROJECTION) { return; } + DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice(); if (resultCode != Activity.RESULT_OK || data == null || selected == null) { Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); return; @@ -154,7 +147,8 @@ public class SenderActivity extends AppCompatActivity { } else { startService(serviceIntent); } + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); Toast.makeText(this, R.string.sender_check_notification, Toast.LENGTH_LONG).show(); - finish(); + moveTaskToBack(true); } } diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml index 43600be..e39cc80 100644 --- a/app/src/main/res/layout/activity_playback.xml +++ b/app/src/main/res/layout/activity_playback.xml @@ -14,6 +14,12 @@ android:id="@+id/surface_playback" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_gravity="center" /> + android:layout_gravity="center" + android:keepScreenOn="true" /> + + diff --git a/app/src/main/res/layout/activity_receiver.xml b/app/src/main/res/layout/activity_receiver.xml index 5213198..ad34a3d 100644 --- a/app/src/main/res/layout/activity_receiver.xml +++ b/app/src/main/res/layout/activity_receiver.xml @@ -25,6 +25,13 @@ android:hint="@string/pin_hint" android:inputType="numberPassword" /> + +