1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 02:59:00 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-15 16:49:02 +02:00
commit 097c83dc69
54 changed files with 3969 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
*.iml
.gradle/
/local.properties
/.idea/
/build/
app/build/
captures/
.DS_Store
*.apk
*.ap_
*.dex

86
README.md Normal file
View File

@@ -0,0 +1,86 @@
# Android Cast (POC)
Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playback audio, encodes **H.264 + AAC**, streams over **TCP or UDP** to a receiver that decodes and plays back.
## Features
- UDP broadcast discovery on the local network
- TCP or UDP stream transport (selectable; must match on both devices)
- PIN auth (SHA-256 hash, default `1234`)
- Screen capture → H.264 (AVC) with quality presets and scaled capture resolution
- Playback audio capture (API 29+) → AAC-LC
- Runtime notification permission (Android 13+)
## Quick start
1. Install the same APK on two devices on the **same WiFi**.
2. **Receiver** (TV): **Receive** → match transport (TCP/UDP) & options → tap **Start listening** (notification appears; service keeps running).
3. **Sender** (tablet): **Send****same transport** → pick receiver → PIN → approve screen capture.
4. Watch the **notification** on both devices for connection status (not only the toast).
5. Stop from the notification **Stop** action.
### If video never appears
- Transport must match on both sides (e.g. both TCP).
- Receiver must show *Listening* or *Ready* in the notification before you cast.
- Sender notification should reach *Casting via TCP · WxH* — if it shows *Screen capture data missing*, reinstall and try again (Android 13+ intent fix).
- Keep the receiver UI open once so the video `Surface` attaches (or return to it after rotation).
## Build
Use **JDK 17 or 21** (Gradle does not run on JDK 25 yet):
```bash
export JAVA_HOME=/usr/lib/jvm/openjdk-bin-21
cd "/home/foxx/repos/My Projects/android cast"
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
## Stream options
| Option | Effect |
|--------|--------|
| **Transport TCP** | Reliable delivery; default |
| **Transport UDP** | Lower latency, may drop frames (POC) |
| **Quality Low/Med/High** | Video bitrate 0.8 / 2 / 4 Mbps and FPS 20 / 24 / 30 |
| **Resolution** | Scales VirtualDisplay before encode (full, 75%, 50%, or max 720p height) |
| **Audio** | `AudioPlaybackCapture` + AAC (Android 10+) |
## Protocol
| Step | Description |
|------|-------------|
| Discovery | UDP `41234`, JSON with `magic`, `name`, `port`, `transport` |
| Connect | TCP or UDP port `41235` |
| Auth | `HELLO``AUTH``AUTH_OK``CAST_SETTINGS` |
| Stream | `STREAM_CONFIG`, `VIDEO_FRAME`, `AUDIO_CONFIG`, `AUDIO_FRAME` |
UDP frames use `ACUD` header and fragmentation for payloads > ~1.2 KB.
## Bluetooth / BLE
**Not viable for this video stream.** Practical throughput:
- **BLE**: ~100300 kb/s — fine for control/metadata only
- **Bluetooth Classic (SPP)**: often ~13 Mb/s theoretical — still far below 720p H.264 (~28 Mb/s), with high latency
A phone cannot “passthrough” a live encoded screen stream over BLE to another device for playback without a custom high-bandwidth profile (which consumer Android does not expose for arbitrary apps). Use **WiFi** for media; BT could be added later only for discovery/pairing if needed.
## Persistent services (AIDL)
Casting runs in **foreground services** with tray notifications:
| Service | Role |
|---------|------|
| `ScreenCastService` | Sender — capture, encode, stream |
| `ReceiverCastService` | Receiver — listen, decode, play |
Activities bind via AIDL (`ICastSenderService` / `ICastReceiverService`) for status updates and (on the receiver) attaching the video `Surface`. Rotating the screen or leaving the app does **not** stop the receiver listener; the sender keeps casting until you tap **Stop** in the notification.
## Limitations
- No TLS encryption (LAN cleartext POC)
- UDP mode may glitch under load
- Audio captures **playback** routed through the system (not microphone); some apps may be silent by policy
- Single sender → single receiver

38
app/build.gradle Normal file
View File

@@ -0,0 +1,38 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.foxx.androidcast'
compileSdk 34
defaultConfig {
applicationId "com.foxx.androidcast"
minSdk 29
targetSdk 34
versionCode 1
versionName "0.1.0-poc"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
aidl true
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
}

1
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# POC no obfuscation rules yet

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidCast"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".sender.SenderActivity"
android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
<activity
android:name=".receiver.ReceiverActivity"
android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
<activity
android:name=".receiver.ReceiverPlaybackActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/Theme.AndroidCast.Fullscreen"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
<service
android:name=".sender.ScreenCastService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<service
android:name=".receiver.ReceiverCastService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>

View File

@@ -0,0 +1,13 @@
package com.foxx.androidcast;
import com.foxx.androidcast.ICastStatusCallback;
import android.view.Surface;
interface ICastReceiverService {
void registerCallback(ICastStatusCallback callback);
void unregisterCallback(ICastStatusCallback callback);
String getStatus();
void attachVideoSurface(in Surface surface);
void detachVideoSurface();
void stopReceiving();
}

View File

@@ -0,0 +1,11 @@
package com.foxx.androidcast;
import com.foxx.androidcast.ICastStatusCallback;
interface ICastSenderService {
void registerCallback(ICastStatusCallback callback);
void unregisterCallback(ICastStatusCallback callback);
String getStatus();
boolean isCasting();
void stopCasting();
}

View File

@@ -0,0 +1,8 @@
package com.foxx.androidcast;
interface ICastStatusCallback {
void onStatus(String status);
void onAuthenticated(String senderName);
void onStreamStarted(int width, int height);
void onDisconnected();
}

View File

@@ -0,0 +1,23 @@
package com.foxx.androidcast;
/** Shared constants for the LAN cast POC. */
public final class CastConfig {
public static final int DISCOVERY_PORT = 41234;
public static final int CAST_PORT = 41235;
public static final String DISCOVERY_MAGIC = "ACAST1";
public static final String DEFAULT_PIN = "1234";
public static final String TRANSPORT_TCP = "tcp";
public static final String TRANSPORT_UDP = "udp";
public static final int VIDEO_I_FRAME_INTERVAL = 2;
public static final int AUDIO_SAMPLE_RATE = 44100;
public static final int AUDIO_CHANNEL_COUNT = 2;
public static final int AUDIO_BITRATE = 128_000;
/** Max UDP payload chunk (fits typical WiFi MTU with header). */
public static final int UDP_CHUNK_SIZE = 1200;
private CastConfig() {}
}

View File

@@ -0,0 +1,61 @@
package com.foxx.androidcast;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.os.Build;
import androidx.core.app.NotificationCompat;
public final class CastNotifications {
public static final String CHANNEL_CAST = "cast_channel";
public static final String CHANNEL_RECEIVE = "receive_channel";
public static final int ID_SENDER = 1;
public static final int ID_RECEIVER = 2;
private CastNotifications() {}
public static void createChannels(Service service) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager nm = service.getSystemService(NotificationManager.class);
NotificationChannel cast = new NotificationChannel(
CHANNEL_CAST, "Screen cast", NotificationManager.IMPORTANCE_LOW);
NotificationChannel recv = new NotificationChannel(
CHANNEL_RECEIVE, "Cast receiver", NotificationManager.IMPORTANCE_LOW);
nm.createNotificationChannel(cast);
nm.createNotificationChannel(recv);
}
public static Notification sender(Service service, String text, Class<?> serviceClass) {
return build(service, CHANNEL_CAST, ID_SENDER, text, serviceClass, "com.foxx.androidcast.STOP_SEND");
}
public static Notification receiver(Service service, String text, Class<?> serviceClass) {
return build(service, CHANNEL_RECEIVE, ID_RECEIVER, text, serviceClass, "com.foxx.androidcast.STOP_RECV");
}
private static Notification build(
Service service, String channel, int id, String text,
Class<?> serviceClass, String stopAction) {
Intent open = new Intent(service, MainActivity.class);
PendingIntent pi = PendingIntent.getActivity(
service, 0, open, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent stop = new Intent(service, serviceClass);
stop.setAction(stopAction);
PendingIntent stopPi = PendingIntent.getService(
service, id, stop, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
return new NotificationCompat.Builder(service, channel)
.setContentTitle(service.getString(R.string.app_name))
.setContentText(text)
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pi)
.addAction(0, service.getString(R.string.stop), stopPi)
.setOngoing(true)
.build();
}
}

View File

@@ -0,0 +1,38 @@
package com.foxx.androidcast;
import android.util.DisplayMetrics;
/** Computes encoder VirtualDisplay size from display metrics and user settings. */
public final class CastResolution {
private CastResolution() {}
public static final class Size {
public final int width;
public final int height;
public final int densityDpi;
public Size(int width, int height, int densityDpi) {
this.width = width;
this.height = height;
this.densityDpi = densityDpi;
}
}
public static Size compute(DisplayMetrics display, CastSettings settings) {
int displayW = display.widthPixels & ~1;
int displayH = display.heightPixels & ~1;
float scale = settings.getResolutionScale();
int encW = Math.max(2, ((int) (displayW * scale)) & ~1);
int encH = Math.max(2, ((int) (displayH * scale)) & ~1);
int maxH = settings.getMaxHeight();
if (maxH > 0 && encH > maxH) {
float ratio = (float) maxH / encH;
encH = maxH & ~1;
encW = Math.max(2, ((int) (encW * ratio)) & ~1);
}
int density = Math.max(120, (int) (display.densityDpi * scale));
return new Size(encW, encH, density);
}
}

View File

@@ -0,0 +1,100 @@
package com.foxx.androidcast;
import java.io.Serializable;
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
public class CastSettings implements Serializable {
public static final String EXTRA = "cast_settings";
public enum Quality {
LOW(800_000, 20),
MEDIUM(2_000_000, 24),
HIGH(4_000_000, 30);
public final int videoBitrate;
public final int videoFps;
Quality(int videoBitrate, int videoFps) {
this.videoBitrate = videoBitrate;
this.videoFps = videoFps;
}
}
/** Capture scale relative to physical display (1.0 = native). */
public enum Resolution {
FULL(1.0f, 0),
THREE_QUARTERS(0.75f, 0),
HALF(0.5f, 0),
HD_720P(1.0f, 720);
public final float scale;
public final int maxHeight;
Resolution(float scale, int maxHeight) {
this.scale = scale;
this.maxHeight = maxHeight;
}
}
private String transport = CastConfig.TRANSPORT_TCP;
private Quality quality = Quality.MEDIUM;
private Resolution resolution = Resolution.FULL;
private boolean audioEnabled;
public CastSettings() {
PlatformCastSupport.applyDefaultAudioSetting(this);
}
public String getTransport() {
return transport;
}
public void setTransport(String transport) {
this.transport = transport;
}
public Quality getQuality() {
return quality;
}
public void setQuality(Quality quality) {
this.quality = quality;
}
public Resolution getResolution() {
return resolution;
}
public void setResolution(Resolution resolution) {
this.resolution = resolution;
}
public boolean isAudioEnabled() {
return audioEnabled && PlatformCastSupport.isAudioCaptureSupported();
}
public void setAudioEnabled(boolean audioEnabled) {
this.audioEnabled = audioEnabled;
}
/** Raw user toggle; may be true on old platforms but {@link #isAudioEnabled()} stays false. */
public boolean isAudioRequested() {
return audioEnabled;
}
public int getVideoBitrate() {
return quality.videoBitrate;
}
public int getVideoFps() {
return quality.videoFps;
}
public float getResolutionScale() {
return resolution.scale;
}
public int getMaxHeight() {
return resolution.maxHeight;
}
}

View File

@@ -0,0 +1,29 @@
package com.foxx.androidcast;
import android.content.Intent;
import android.os.Build;
/** API 33+ safe extras helpers. */
public final class IntentExtras {
private IntentExtras() {}
public static Intent getParcelableIntent(Intent intent, String key) {
if (intent == null) {
return null;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return intent.getParcelableExtra(key, Intent.class);
}
return intent.getParcelableExtra(key);
}
public static CastSettings getCastSettings(Intent intent) {
if (intent == null) {
return null;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return intent.getSerializableExtra(CastSettings.EXTRA, CastSettings.class);
}
return (CastSettings) intent.getSerializableExtra(CastSettings.EXTRA);
}
}

View File

@@ -0,0 +1,31 @@
package com.foxx.androidcast;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.receiver.ReceiverActivity;
import com.foxx.androidcast.sender.SenderActivity;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
PermissionHelper.request(this);
Button sendBtn = findViewById(R.id.btn_send);
Button recvBtn = findViewById(R.id.btn_receive);
sendBtn.setOnClickListener(v ->
startActivity(new Intent(this, SenderActivity.class)));
recvBtn.setOnClickListener(v ->
startActivity(new Intent(this, ReceiverActivity.class)));
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}

View File

@@ -0,0 +1,42 @@
package com.foxx.androidcast;
import android.Manifest;
import android.app.Activity;
import android.content.pm.PackageManager;
import android.os.Build;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import java.util.ArrayList;
import java.util.List;
/** Requests runtime permissions needed for LAN cast POC. */
public final class PermissionHelper {
public static final int REQUEST_CODE = 1001;
private PermissionHelper() {}
public static boolean hasAll(Activity activity) {
return missing(activity).isEmpty();
}
public static void request(Activity activity) {
List<String> missing = missing(activity);
if (!missing.isEmpty()) {
ActivityCompat.requestPermissions(
activity, missing.toArray(new String[0]), REQUEST_CODE);
}
}
private static List<String> missing(Activity activity) {
List<String> needed = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
needed.add(Manifest.permission.POST_NOTIFICATIONS);
}
}
return needed;
}
}

View File

@@ -0,0 +1,55 @@
package com.foxx.androidcast;
import android.os.Build;
import android.widget.CheckBox;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
/** Platform capabilities for cast (audio requires Android 10 / API 29+). */
public final class PlatformCastSupport {
private PlatformCastSupport() {}
public static boolean isAudioCaptureSupported() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
}
/** Applies default audio flag: on for Android 10+, off otherwise. */
public static void applyDefaultAudioSetting(CastSettings settings) {
settings.setAudioEnabled(isAudioCaptureSupported());
}
/** Disables audio UI and forces video-only when capture is unavailable. */
public static void bindAudioCheckbox(AppCompatActivity activity, CheckBox audio, CastSettings settings) {
applyDefaultAudioSetting(settings);
if (isAudioCaptureSupported()) {
audio.setEnabled(true);
audio.setText(R.string.label_audio);
audio.setChecked(settings.isAudioEnabled());
audio.setOnCheckedChangeListener((btn, checked) -> settings.setAudioEnabled(checked));
} else {
settings.setAudioEnabled(false);
audio.setEnabled(false);
audio.setChecked(false);
audio.setText(R.string.label_audio_unavailable);
}
}
/**
* If audio is unavailable, warns the user and runs {@code onContinue} only when they accept video-only.
* On Android 10+, runs {@code onContinue} immediately.
*/
public static void runWithAudioPolicy(AppCompatActivity activity, CastSettings settings, Runnable onContinue) {
if (isAudioCaptureSupported()) {
onContinue.run();
return;
}
settings.setAudioEnabled(false);
new AlertDialog.Builder(activity)
.setTitle(R.string.audio_unavailable_title)
.setMessage(R.string.audio_unavailable_message)
.setPositiveButton(R.string.continue_video_only, (d, w) -> onContinue.run())
.setNegativeButton(android.R.string.cancel, null)
.show();
}
}

View File

@@ -0,0 +1,76 @@
package com.foxx.androidcast;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.Spinner;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.R;
/** Binds cast option spinners/checkbox to {@link CastSettings}. */
public final class SettingsUi {
private SettingsUi() {}
public static void bind(AppCompatActivity activity, CastSettings settings) {
PlatformCastSupport.applyDefaultAudioSetting(settings);
Spinner transport = activity.findViewById(R.id.spinner_transport);
Spinner quality = activity.findViewById(R.id.spinner_quality);
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
CheckBox audio = activity.findViewById(R.id.checkbox_audio);
String[] transports = {
activity.getString(R.string.transport_tcp),
activity.getString(R.string.transport_udp)
};
transport.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, transports));
transport.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
settings.setTransport(position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {}
});
String[] qualities = {
activity.getString(R.string.quality_low),
activity.getString(R.string.quality_medium),
activity.getString(R.string.quality_high)
};
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities));
quality.setSelection(1);
quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
settings.setQuality(CastSettings.Quality.values()[position]);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {}
});
String[] resolutions = {
activity.getString(R.string.resolution_full),
activity.getString(R.string.resolution_75),
activity.getString(R.string.resolution_half),
activity.getString(R.string.resolution_720p)
};
resolution.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, resolutions));
resolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
settings.setResolution(CastSettings.Resolution.values()[position]);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {}
});
PlatformCastSupport.bindAudioCheckbox(activity, audio, settings);
}
}

View File

@@ -0,0 +1,186 @@
package com.foxx.androidcast.discovery;
import android.content.Context;
import android.net.wifi.WifiManager;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.foxx.androidcast.CastConfig;
import org.json.JSONObject;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/** UDP broadcast discovery of receivers on the same LAN. */
public class DiscoveryManager {
private static final String TAG = "DiscoveryManager";
public interface Listener {
void onDevicesUpdated(List<DiscoveredDevice> devices);
}
public static final class DiscoveredDevice {
public final String name;
public final String host;
public final int port;
public final String transport;
public final long lastSeenMs;
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
this.name = name;
this.host = host;
this.port = port;
this.transport = transport;
this.lastSeenMs = lastSeenMs;
}
}
private final Context appContext;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final ExecutorService executor = Executors.newCachedThreadPool();
private final AtomicBoolean running = new AtomicBoolean(false);
private final ConcurrentHashMap<String, DiscoveredDevice> devices = new ConcurrentHashMap<>();
private Listener listener;
private WifiManager.MulticastLock multicastLock;
private DatagramSocket socket;
public DiscoveryManager(Context context) {
this.appContext = context.getApplicationContext();
}
public void setListener(Listener listener) {
this.listener = listener;
}
public void startBrowsing() {
if (!running.compareAndSet(false, true)) {
return;
}
executor.execute(this::browseLoop);
}
public void stop() {
running.set(false);
if (socket != null) {
socket.close();
}
releaseMulticastLock();
devices.clear();
}
public void startAnnouncing(String deviceName, int port, String transport) {
if (!running.compareAndSet(false, true)) {
return;
}
executor.execute(() -> announceLoop(deviceName, port, transport));
}
private void browseLoop() {
acquireMulticastLock();
try (DatagramSocket sock = new DatagramSocket(null)) {
socket = sock;
sock.setReuseAddress(true);
sock.bind(new InetSocketAddress(CastConfig.DISCOVERY_PORT));
sock.setBroadcast(true);
byte[] buf = new byte[1024];
while (running.get()) {
DatagramPacket packet = new DatagramPacket(buf, buf.length);
sock.receive(packet);
handlePacket(packet);
}
} catch (Exception e) {
if (running.get()) {
Log.e(TAG, "Browse failed", e);
}
} finally {
releaseMulticastLock();
}
}
private void announceLoop(String deviceName, int port, String transport) {
acquireMulticastLock();
try (DatagramSocket sock = new DatagramSocket()) {
socket = sock;
sock.setBroadcast(true);
while (running.get()) {
JSONObject json = new JSONObject();
json.put("magic", CastConfig.DISCOVERY_MAGIC);
json.put("name", deviceName);
json.put("port", port);
json.put("transport", transport);
byte[] data = json.toString().getBytes(StandardCharsets.UTF_8);
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(data, data.length, broadcast, CastConfig.DISCOVERY_PORT);
sock.send(packet);
Thread.sleep(2000);
}
} catch (Exception e) {
if (running.get()) {
Log.e(TAG, "Announce failed", e);
}
} finally {
releaseMulticastLock();
}
}
private void handlePacket(DatagramPacket packet) {
try {
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
JSONObject json = new JSONObject(text);
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
return;
}
String name = json.optString("name", "Unknown");
int port = json.optInt("port", CastConfig.CAST_PORT);
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
String host = packet.getAddress().getHostAddress();
String key = host + ":" + port;
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, System.currentTimeMillis());
devices.put(key, device);
notifyListener();
} catch (Exception ignored) {
}
}
private void notifyListener() {
if (listener == null) {
return;
}
long now = System.currentTimeMillis();
List<DiscoveredDevice> fresh = new ArrayList<>();
for (DiscoveredDevice d : devices.values()) {
if (now - d.lastSeenMs < 10_000) {
fresh.add(d);
}
}
mainHandler.post(() -> listener.onDevicesUpdated(fresh));
}
private void acquireMulticastLock() {
WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE);
if (wifi != null) {
multicastLock = wifi.createMulticastLock("androidcast-discovery");
multicastLock.setReferenceCounted(true);
multicastLock.acquire();
}
}
private void releaseMulticastLock() {
if (multicastLock != null && multicastLock.isHeld()) {
multicastLock.release();
}
multicastLock = null;
}
}

View File

@@ -0,0 +1,56 @@
package com.foxx.androidcast.media;
import java.io.ByteArrayOutputStream;
/** Converts H.264 AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. */
public final class H264Bitstream {
private static final byte[] START_CODE = {0, 0, 0, 1};
private H264Bitstream() {}
public static byte[] toAnnexB(byte[] data) {
if (data == null || data.length < 4) {
return data;
}
if (startsWithAnnexB(data)) {
return data;
}
if (!looksLikeAvcc(data)) {
return data;
}
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + 16);
int offset = 0;
while (offset + 4 <= data.length) {
int length = readLength(data, offset);
if (length <= 0 || offset + 4 + length > data.length) {
break;
}
out.write(START_CODE, 0, START_CODE.length);
out.write(data, offset + 4, length);
offset += 4 + length;
}
if (out.size() == 0) {
return data;
}
return out.toByteArray();
}
private static boolean startsWithAnnexB(byte[] data) {
return data.length >= 4
&& data[0] == 0
&& data[1] == 0
&& (data[2] == 1 || (data[2] == 0 && data[3] == 1));
}
private static boolean looksLikeAvcc(byte[] data) {
int length = readLength(data, 0);
return length > 0 && length < data.length;
}
private static int readLength(byte[] data, int offset) {
return ((data[offset] & 0xFF) << 24)
| ((data[offset + 1] & 0xFF) << 16)
| ((data[offset + 2] & 0xFF) << 8)
| (data[offset + 3] & 0xFF);
}
}

View File

@@ -0,0 +1,263 @@
package com.foxx.androidcast.network;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/** Length-prefixed binary protocol (TCP) and payload format (shared with UDP). */
public final class CastProtocol {
public static final byte MSG_HELLO = 1;
public static final byte MSG_AUTH = 2;
public static final byte MSG_AUTH_OK = 3;
public static final byte MSG_AUTH_FAIL = 4;
public static final byte MSG_STREAM_CONFIG = 5;
public static final byte MSG_VIDEO_FRAME = 6;
public static final byte MSG_GOODBYE = 7;
public static final byte MSG_AUDIO_CONFIG = 8;
public static final byte MSG_AUDIO_FRAME = 9;
public static final byte MSG_CAST_SETTINGS = 10;
private CastProtocol() {}
public static void writeMessage(DataOutputStream out, byte type, byte[] payload) throws IOException {
out.writeByte(type);
int len = payload == null ? 0 : payload.length;
out.writeInt(len);
if (len > 0) {
out.write(payload);
}
out.flush();
}
public static Message readMessage(DataInputStream in) throws IOException {
byte type = in.readByte();
int len = in.readInt();
if (len < 0 || len > 4 * 1024 * 1024) {
throw new IOException("Invalid payload length: " + len);
}
byte[] payload = len == 0 ? new byte[0] : new byte[len];
if (len > 0) {
in.readFully(payload);
}
return new Message(type, payload);
}
public static byte[] helloPayload(String deviceName) {
return deviceName.getBytes(StandardCharsets.UTF_8);
}
public static byte[] authPayload(String pin) {
return sha256(pin).getBytes(StandardCharsets.UTF_8);
}
public static byte[] castSettingsPayload(CastSettings settings) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeUTF(settings.getTransport());
dos.writeInt(settings.getQuality().ordinal());
dos.writeInt(settings.getResolution().ordinal());
dos.writeByte(settings.isAudioEnabled() ? 1 : 0);
dos.flush();
return bos.toByteArray();
}
public static CastSettings parseCastSettings(byte[] payload) throws IOException {
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
CastSettings settings = new CastSettings();
settings.setTransport(dis.readUTF());
int q = dis.readInt();
int r = dis.readInt();
settings.setQuality(CastSettings.Quality.values()[q]);
settings.setResolution(CastSettings.Resolution.values()[r]);
settings.setAudioEnabled(dis.readByte() != 0);
return settings;
}
public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeInt(width);
dos.writeInt(height);
writeBytes(dos, csd0);
writeBytes(dos, csd1);
dos.flush();
return bos.toByteArray();
}
public static byte[] audioConfigPayload(int sampleRate, int channels, byte[] csd0) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeInt(sampleRate);
dos.writeInt(channels);
writeBytes(dos, csd0);
dos.flush();
return bos.toByteArray();
}
public static byte[] videoFramePayload(long ptsUs, boolean keyFrame, byte[] data) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeLong(ptsUs);
dos.writeByte(keyFrame ? 1 : 0);
writeBytes(dos, data);
dos.flush();
return bos.toByteArray();
}
public static byte[] audioFramePayload(long ptsUs, byte[] data) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeLong(ptsUs);
writeBytes(dos, data);
dos.flush();
return bos.toByteArray();
}
public static StreamConfig parseStreamConfig(byte[] payload) throws IOException {
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
int width = dis.readInt();
int height = dis.readInt();
byte[] csd0 = readBytes(dis);
byte[] csd1 = readBytes(dis);
return new StreamConfig(width, height, csd0, csd1);
}
public static AudioConfig parseAudioConfig(byte[] payload) throws IOException {
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
int sampleRate = dis.readInt();
int channels = dis.readInt();
byte[] csd0 = readBytes(dis);
return new AudioConfig(sampleRate, channels, csd0);
}
public static VideoFrame parseVideoFrame(byte[] payload) throws IOException {
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
long ptsUs = dis.readLong();
boolean keyFrame = dis.readByte() != 0;
byte[] data = readBytes(dis);
return new VideoFrame(ptsUs, keyFrame, data);
}
public static AudioFrame parseAudioFrame(byte[] payload) throws IOException {
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
long ptsUs = dis.readLong();
byte[] data = readBytes(dis);
return new AudioFrame(ptsUs, data);
}
public static String payloadAsUtf8(byte[] payload) {
return new String(payload, StandardCharsets.UTF_8);
}
public static boolean pinMatches(byte[] authHashUtf8, String pin) {
String expected = sha256(pin);
String received = new String(authHashUtf8, StandardCharsets.UTF_8);
return expected.equals(received);
}
public static boolean transportMatches(CastSettings local, CastSettings remote) {
return local.getTransport().equals(remote.getTransport());
}
private static void writeBytes(DataOutputStream dos, byte[] data) throws IOException {
if (data == null) {
dos.writeInt(0);
return;
}
dos.writeInt(data.length);
if (data.length > 0) {
dos.write(data);
}
}
private static byte[] readBytes(DataInputStream dis) throws IOException {
int len = dis.readInt();
if (len == 0) {
return new byte[0];
}
if (len < 0 || len > 512 * 1024) {
throw new IOException("Invalid blob length");
}
byte[] buf = new byte[len];
dis.readFully(buf);
return buf;
}
private static String sha256(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
public static final class Message {
public final byte type;
public final byte[] payload;
public Message(byte type, byte[] payload) {
this.type = type;
this.payload = payload;
}
}
public static final class StreamConfig {
public final int width;
public final int height;
public final byte[] csd0;
public final byte[] csd1;
public StreamConfig(int width, int height, byte[] csd0, byte[] csd1) {
this.width = width;
this.height = height;
this.csd0 = csd0;
this.csd1 = csd1;
}
}
public static final class AudioConfig {
public final int sampleRate;
public final int channels;
public final byte[] csd0;
public AudioConfig(int sampleRate, int channels, byte[] csd0) {
this.sampleRate = sampleRate;
this.channels = channels;
this.csd0 = csd0;
}
}
public static final class VideoFrame {
public final long ptsUs;
public final boolean keyFrame;
public final byte[] data;
public VideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
this.ptsUs = ptsUs;
this.keyFrame = keyFrame;
this.data = data;
}
}
public static final class AudioFrame {
public final long ptsUs;
public final byte[] data;
public AudioFrame(long ptsUs, byte[] data) {
this.ptsUs = ptsUs;
this.data = data;
}
}
}

View File

@@ -0,0 +1,97 @@
package com.foxx.androidcast.network;
import com.foxx.androidcast.CastSettings;
import java.io.IOException;
/** Auth handshake and message I/O over a {@link CastTransport}. */
public class CastSession {
public static final class HandshakeResult {
public final String senderName;
public final CastSettings settings;
public HandshakeResult(String senderName, CastSettings settings) {
this.senderName = senderName;
this.settings = settings;
}
}
private final CastTransport transport;
public CastSession(CastTransport transport) {
this.transport = transport;
}
public CastTransport getTransport() {
return transport;
}
public void listen(int port) throws IOException {
transport.listen(port);
}
public void connect(String host, int port) throws IOException {
transport.connect(host, port);
}
public void clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException {
transport.send(CastProtocol.MSG_HELLO, CastProtocol.helloPayload(deviceName));
transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin));
CastProtocol.Message reply = transport.receive(10_000);
if (reply == null || reply.type != CastProtocol.MSG_AUTH_OK) {
throw new IOException("Authentication failed");
}
transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings));
}
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
String senderName = "Sender";
boolean authed = false;
long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline) {
CastProtocol.Message msg = transport.receive(2_000);
if (msg == null) {
continue;
}
switch (msg.type) {
case CastProtocol.MSG_HELLO:
senderName = CastProtocol.payloadAsUtf8(msg.payload);
break;
case CastProtocol.MSG_AUTH:
if (CastProtocol.pinMatches(msg.payload, expectedPin)) {
authed = true;
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
} else {
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
throw new IOException("Bad PIN");
}
break;
case CastProtocol.MSG_CAST_SETTINGS:
if (!authed) {
break;
}
CastSettings remote = CastProtocol.parseCastSettings(msg.payload);
if (!CastProtocol.transportMatches(localSettings, remote)) {
throw new IOException("Transport mismatch: receiver="
+ localSettings.getTransport() + " sender=" + remote.getTransport());
}
return new HandshakeResult(senderName, remote);
default:
break;
}
}
throw new IOException("Handshake timeout");
}
public void send(byte type, byte[] payload) throws IOException {
transport.send(type, payload);
}
public CastProtocol.Message receive(int timeoutMs) throws IOException {
return transport.receive(timeoutMs);
}
public void close() {
transport.close();
}
}

View File

@@ -0,0 +1,23 @@
package com.foxx.androidcast.network;
import java.io.Closeable;
import java.io.IOException;
/** Pluggable stream transport (TCP or UDP). */
public interface CastTransport extends Closeable {
/** Server: bind and wait for first peer datagram / connection. */
void listen(int port) throws IOException;
/** Client: connect to host:port. */
void connect(String host, int port) throws IOException;
void send(byte type, byte[] payload) throws IOException;
/** Blocks until a message arrives or timeout. Returns null on timeout. */
CastProtocol.Message receive(int timeoutMs) throws IOException;
String getModeLabel();
@Override
void close();
}

View File

@@ -0,0 +1,17 @@
package com.foxx.androidcast.network;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import java.io.IOException;
public final class CastTransportFactory {
private CastTransportFactory() {}
public static CastTransport create(CastSettings settings) throws IOException {
if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) {
return new UdpCastTransport();
}
return new TcpCastTransport();
}
}

View File

@@ -0,0 +1,82 @@
package com.foxx.androidcast.network;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
/** Reliable message transport over TCP. */
public class TcpCastTransport implements CastTransport {
private ServerSocket serverSocket;
private Socket socket;
private DataInputStream in;
private DataOutputStream out;
@Override
public void listen(int port) throws IOException {
serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.bind(new InetSocketAddress(port));
socket = serverSocket.accept();
socket.setTcpNoDelay(true);
openStreams();
serverSocket.close();
serverSocket = null;
}
@Override
public void connect(String host, int port) throws IOException {
socket = new Socket(host, port);
socket.setTcpNoDelay(true);
openStreams();
}
private void openStreams() throws IOException {
in = new DataInputStream(socket.getInputStream());
out = new DataOutputStream(socket.getOutputStream());
}
@Override
public void send(byte type, byte[] payload) throws IOException {
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;
}
}
@Override
public String getModeLabel() {
return "TCP";
}
@Override
public void close() {
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
try {
if (serverSocket != null) {
serverSocket.close();
}
} catch (Exception ignored) {
}
socket = null;
serverSocket = null;
}
}

View File

@@ -0,0 +1,215 @@
package com.foxx.androidcast.network;
import com.foxx.androidcast.CastConfig;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketTimeoutException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Best-effort message transport over UDP with fragmentation. */
public class UdpCastTransport implements CastTransport {
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
private static final int HEADER = 16;
private DatagramSocket socket;
private InetSocketAddress peer;
private int nextMessageId = 1;
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
@Override
public void listen(int port) throws IOException {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(port));
socket.setSoTimeout(500);
// peer set on first received packet
}
@Override
public void connect(String host, int port) throws IOException {
socket = new DatagramSocket();
peer = new InetSocketAddress(host, port);
socket.setSoTimeout(500);
}
@Override
public void send(byte type, byte[] payload) throws IOException {
if (peer == null) {
throw new IOException("UDP peer not set");
}
byte[] body = payload == null ? new byte[0] : payload;
int messageId = nextMessageId++;
int chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
for (int i = 0; i < fragments; i++) {
int offset = i * chunkSize;
int len = Math.min(chunkSize, body.length - offset);
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
socket.send(dp);
}
}
@Override
public CastProtocol.Message receive(int timeoutMs) throws IOException {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
socket.setSoTimeout(remaining);
try {
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
DatagramPacket dp = new DatagramPacket(buf, buf.length);
socket.receive(dp);
if (peer == null) {
peer = new InetSocketAddress(dp.getAddress(), dp.getPort());
}
CastProtocol.Message msg = parsePacket(dp.getData(), dp.getLength());
if (msg != null) {
return msg;
}
} catch (SocketTimeoutException ignored) {
}
}
return null;
}
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
if (length < HEADER) {
return null;
}
for (int i = 0; i < 4; i++) {
if (data[i] != MAGIC[i]) {
return null;
}
}
byte type = data[4];
int messageId = readInt(data, 5);
int fragIndex = readShort(data, 9);
int fragCount = readShort(data, 11);
int totalLen = readInt(data, 13);
int payloadLen = length - HEADER;
byte[] chunk = new byte[payloadLen];
System.arraycopy(data, HEADER, chunk, 0, payloadLen);
if (fragCount <= 1) {
if (totalLen != payloadLen) {
byte[] trimmed = new byte[totalLen];
System.arraycopy(chunk, 0, trimmed, 0, Math.min(totalLen, payloadLen));
return new CastProtocol.Message(type, trimmed);
}
return new CastProtocol.Message(type, chunk);
}
Reassembly r = pending.get(messageId);
if (r == null || r.fragCount != fragCount || r.totalLen != totalLen) {
r = new Reassembly(fragCount, totalLen);
pending.put(messageId, r);
}
r.put(fragIndex, chunk, payloadLen);
if (!r.complete()) {
return null;
}
pending.remove(messageId);
return new CastProtocol.Message(type, r.assemble());
}
private static byte[] buildPacket(
byte type, int messageId, int fragIndex, int fragCount,
byte[] body, int offset, int len, int totalLen) {
byte[] packet = new byte[HEADER + len];
System.arraycopy(MAGIC, 0, packet, 0, 4);
packet[4] = type;
writeInt(packet, 5, messageId);
writeShort(packet, 9, fragIndex);
writeShort(packet, 11, fragCount);
writeInt(packet, 13, totalLen);
System.arraycopy(body, offset, packet, HEADER, len);
return packet;
}
private static int readInt(byte[] b, int off) {
return ((b[off] & 0xFF) << 24) | ((b[off + 1] & 0xFF) << 16)
| ((b[off + 2] & 0xFF) << 8) | (b[off + 3] & 0xFF);
}
private static int readShort(byte[] b, int off) {
return ((b[off] & 0xFF) << 8) | (b[off + 1] & 0xFF);
}
private static void writeInt(byte[] b, int off, int v) {
b[off] = (byte) (v >> 24);
b[off + 1] = (byte) (v >> 16);
b[off + 2] = (byte) (v >> 8);
b[off + 3] = (byte) v;
}
private static void writeShort(byte[] b, int off, int v) {
b[off] = (byte) (v >> 8);
b[off + 1] = (byte) v;
}
@Override
public String getModeLabel() {
return "UDP";
}
@Override
public void close() {
if (socket != null) {
socket.close();
socket = null;
}
pending.clear();
}
private static final class Reassembly {
final int fragCount;
final int totalLen;
final byte[][] parts;
final int[] sizes;
int received;
Reassembly(int fragCount, int totalLen) {
this.fragCount = fragCount;
this.totalLen = totalLen;
this.parts = new byte[fragCount][];
this.sizes = new int[fragCount];
}
void put(int index, byte[] data, int len) {
if (index < 0 || index >= fragCount || parts[index] != null) {
return;
}
byte[] copy = new byte[len];
System.arraycopy(data, 0, copy, 0, len);
parts[index] = copy;
sizes[index] = len;
received++;
}
boolean complete() {
return received == fragCount;
}
byte[] assemble() {
ByteArrayOutputStream bos = new ByteArrayOutputStream(totalLen);
for (int i = 0; i < fragCount; i++) {
bos.write(parts[i], 0, sizes[i]);
}
byte[] out = bos.toByteArray();
if (out.length > totalLen) {
byte[] trimmed = new byte[totalLen];
System.arraycopy(out, 0, trimmed, 0, totalLen);
return trimmed;
}
return out;
}
}
}

View File

@@ -0,0 +1,105 @@
package com.foxx.androidcast.receiver;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioTrack;
import android.media.MediaCodec;
import android.media.MediaFormat;
import android.util.Log;
import java.io.IOException;
import java.nio.ByteBuffer;
/** AAC decoder → AudioTrack playback. */
public class AudioDecoder {
private static final String TAG = "AudioDecoder";
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
private MediaCodec codec;
private AudioTrack track;
private boolean configured;
public void configure(int sampleRate, int channels, byte[] csd0) throws IOException {
MediaFormat format = MediaFormat.createAudioFormat(MIME, sampleRate, channels);
if (csd0 != null && csd0.length > 0) {
format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0));
}
codec = MediaCodec.createDecoderByType(MIME);
codec.configure(format, null, null, 0);
codec.start();
int channelMask = channels >= 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO;
int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
track = new AudioTrack.Builder()
.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build())
.setAudioFormat(new AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(channelMask)
.build())
.setBufferSizeInBytes(minBuf * 4)
.setTransferMode(AudioTrack.MODE_STREAM)
.build();
track.play();
configured = true;
}
public void queueFrame(long ptsUs, byte[] data) {
if (!configured || codec == null) {
return;
}
try {
int inIndex = codec.dequeueInputBuffer(5_000);
if (inIndex < 0) {
return;
}
ByteBuffer buffer = codec.getInputBuffer(inIndex);
if (buffer == null) {
return;
}
buffer.clear();
buffer.put(data);
codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, 0);
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
int outIndex = codec.dequeueOutputBuffer(info, 5_000);
while (outIndex >= 0) {
ByteBuffer outBuf = codec.getOutputBuffer(outIndex);
if (outBuf != null && info.size > 0 && track != null) {
byte[] pcm = new byte[info.size];
outBuf.position(info.offset);
outBuf.limit(info.offset + info.size);
outBuf.get(pcm);
track.write(pcm, 0, pcm.length);
}
codec.releaseOutputBuffer(outIndex, false);
outIndex = codec.dequeueOutputBuffer(info, 0);
}
} catch (Exception e) {
Log.e(TAG, "Audio decode error", e);
}
}
public void release() {
configured = false;
if (codec != null) {
try {
codec.stop();
} catch (Exception ignored) {
}
codec.release();
codec = null;
}
if (track != null) {
try {
track.stop();
} catch (Exception ignored) {
}
track.release();
track = null;
}
}
}

View File

@@ -0,0 +1,128 @@
package com.foxx.androidcast.receiver;
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;
import android.text.TextUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
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. */
public class ReceiverActivity extends AppCompatActivity {
private final CastSettings castSettings = new CastSettings();
private TextView statusText;
private EditText pinInput;
private ICastReceiverService receiverService;
private boolean bound;
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
@Override
public void onStatus(String status) {
runOnUiThread(() -> statusText.setText(status));
}
@Override
public void onAuthenticated(String senderName) {
runOnUiThread(() -> statusText.setText(getString(R.string.casting_from, senderName)));
}
@Override
public void onStreamStarted(int width, int height) {
runOnUiThread(() -> statusText.setText(getString(R.string.playback_opened, width, height)));
}
@Override
public void onDisconnected() {
runOnUiThread(() -> statusText.setText(R.string.waiting_sender));
}
};
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);
statusText.setText(receiverService.getStatus());
} catch (RemoteException e) {
statusText.setText(e.getMessage());
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
receiverService = null;
bound = false;
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_receiver);
PermissionHelper.request(this);
SettingsUi.bind(this, castSettings);
statusText = findViewById(R.id.text_status);
pinInput = findViewById(R.id.edit_pin);
pinInput.setText(CastConfig.DEFAULT_PIN);
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);
}
@Override
protected void onStop() {
if (bound && receiverService != null) {
try {
receiverService.unregisterCallback(statusCallback);
} catch (RemoteException ignored) {
}
unbindService(connection);
bound = false;
receiverService = null;
}
super.onStop();
}
private void startReceiverService() {
String pin = pinInput.getText().toString().trim();
if (TextUtils.isEmpty(pin)) {
pin = CastConfig.DEFAULT_PIN;
}
Intent intent = new Intent(this, ReceiverCastService.class);
intent.setAction(ReceiverCastService.ACTION_START);
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
intent.putExtra(CastSettings.EXTRA, castSettings);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent);
} else {
startService(intent);
}
statusText.setText(R.string.receiver_starting);
}
}

View File

@@ -0,0 +1,321 @@
package com.foxx.androidcast.receiver;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
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;
import com.foxx.androidcast.ICastStatusCallback;
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). */
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";
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private DiscoveryManager discovery;
private ReceiverSession session;
private VideoDecoder videoDecoder;
private AudioDecoder audioDecoder;
private Surface attachedSurface;
private String status = "";
private CastSettings settings = new CastSettings();
private boolean started;
private int pendingWidth;
private int pendingHeight;
private byte[] pendingCsd0;
private byte[] pendingCsd1;
private boolean hasPendingConfig;
private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() {
@Override
public void registerCallback(ICastStatusCallback callback) {
if (callback != null) {
callbacks.register(callback);
broadcastStatus(status);
}
}
@Override
public void unregisterCallback(ICastStatusCallback callback) {
if (callback != null) {
callbacks.unregister(callback);
}
}
@Override
public String getStatus() {
return status;
}
@Override
public void attachVideoSurface(Surface surface) {
mainHandler.post(() -> attachSurface(surface));
}
@Override
public void detachVideoSurface() {
mainHandler.post(ReceiverCastService.this::detachSurfaceOnly);
}
@Override
public void stopReceiving() {
stopSelfSafely();
}
};
@Override
public void onCreate() {
super.onCreate();
CastNotifications.createChannels(this);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
stopSelfSafely();
return START_NOT_STICKY;
}
if (intent != null && ACTION_START.equals(intent.getAction())) {
String pin = intent.getStringExtra(EXTRA_PIN);
if (pin == null || pin.isEmpty()) {
pin = CastConfig.DEFAULT_PIN;
}
CastSettings fromIntent = IntentExtras.getCastSettings(intent);
if (fromIntent != null) {
settings = fromIntent;
}
startReceiving(pin);
}
return START_STICKY;
}
private void startReceiving(String pin) {
if (started) {
return;
}
started = true;
startForeground(CastNotifications.ID_RECEIVER,
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
videoDecoder = new VideoDecoder();
audioDecoder = new AudioDecoder();
discovery = new DiscoveryManager(this);
discovery.startAnnouncing(android.os.Build.MODEL, CastConfig.CAST_PORT, settings.getTransport());
session = new ReceiverSession(pin, settings, new ReceiverSession.Listener() {
@Override
public void onStatus(String s) {
updateStatus(s);
}
@Override
public void onAuthenticated(String senderName, CastSettings remoteSettings) {
updateStatus(getString(R.string.casting_from, senderName));
broadcastAuthenticated(senderName);
}
@Override
public void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1) {
mainHandler.post(() -> onStreamConfigReceived(width, height, csd0, csd1));
}
@Override
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
if (videoDecoder != null) {
videoDecoder.queueFrame(ptsUs, keyFrame, data);
}
}
@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());
}
});
}
@Override
public void onAudioFrame(long ptsUs, byte[] data) {
if (audioDecoder != null) {
audioDecoder.queueFrame(ptsUs, data);
}
}
@Override
public void onDisconnected() {
broadcastDisconnected();
updateStatus(getString(R.string.waiting_sender));
}
});
session.start();
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
}
private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) {
pendingWidth = width;
pendingHeight = height;
pendingCsd0 = csd0;
pendingCsd1 = csd1;
hasPendingConfig = true;
launchPlaybackActivity(width, height);
tryConfigureDecoder();
}
private void launchPlaybackActivity(int width, int height) {
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
intent.putExtra(ReceiverPlaybackActivity.EXTRA_WIDTH, width);
intent.putExtra(ReceiverPlaybackActivity.EXTRA_HEIGHT, height);
startActivity(intent);
}
private void tryConfigureDecoder() {
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
return;
}
try {
if (videoDecoder == null) {
videoDecoder = new VideoDecoder();
}
videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface);
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
broadcastStreamStarted(pendingWidth, pendingHeight);
hasPendingConfig = false;
} catch (Exception e) {
Log.e(TAG, "Video configure failed", e);
updateStatus("Video: " + e.getMessage());
}
}
private void attachSurface(Surface surface) {
attachedSurface = surface;
tryConfigureDecoder();
}
private void detachSurfaceOnly() {
attachedSurface = null;
if (videoDecoder != null) {
videoDecoder.release();
videoDecoder = new VideoDecoder();
}
hasPendingConfig = pendingWidth > 0 && pendingHeight > 0;
}
private void updateStatus(String s) {
status = s;
broadcastStatus(s);
NotificationManager nm = getSystemService(NotificationManager.class);
nm.notify(CastNotifications.ID_RECEIVER,
CastNotifications.receiver(this, s, ReceiverCastService.class));
}
private void broadcastStatus(String s) {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
try {
callbacks.getBroadcastItem(i).onStatus(s);
} catch (RemoteException ignored) {
}
}
callbacks.finishBroadcast();
}
private void broadcastAuthenticated(String name) {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
try {
callbacks.getBroadcastItem(i).onAuthenticated(name);
} catch (RemoteException ignored) {
}
}
callbacks.finishBroadcast();
}
private void broadcastStreamStarted(int w, int h) {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
try {
callbacks.getBroadcastItem(i).onStreamStarted(w, h);
} catch (RemoteException ignored) {
}
}
callbacks.finishBroadcast();
}
private void broadcastDisconnected() {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
try {
callbacks.getBroadcastItem(i).onDisconnected();
} catch (RemoteException ignored) {
}
}
callbacks.finishBroadcast();
}
private void releaseAll() {
started = false;
hasPendingConfig = false;
if (discovery != null) {
discovery.stop();
discovery = null;
}
if (session != null) {
session.stop();
session = null;
}
if (videoDecoder != null) {
videoDecoder.release();
videoDecoder = null;
}
if (audioDecoder != null) {
audioDecoder.release();
audioDecoder = null;
}
attachedSurface = null;
}
private void stopSelfSafely() {
releaseAll();
callbacks.kill();
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
@Override
public void onDestroy() {
releaseAll();
callbacks.kill();
super.onDestroy();
}
}

View File

@@ -0,0 +1,162 @@
package com.foxx.androidcast.receiver;
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;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.WindowManager;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.R;
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
/** Fullscreen video-only playback (no controls overlay). */
public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback {
public static final String EXTRA_WIDTH = "video_width";
public static final String EXTRA_HEIGHT = "video_height";
private AspectRatioFrameLayout aspectContainer;
private SurfaceView surfaceView;
private SurfaceHolder surfaceHolder;
private int videoWidth;
private int videoHeight;
private ICastReceiverService receiverService;
private boolean bound;
private final ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
receiverService = ICastReceiverService.Stub.asInterface(service);
bound = true;
attachSurfaceIfReady();
}
@Override
public void onServiceDisconnected(ComponentName name) {
receiverService = null;
bound = false;
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_playback);
applyFullscreen();
videoWidth = getIntent().getIntExtra(EXTRA_WIDTH, 0);
videoHeight = getIntent().getIntExtra(EXTRA_HEIGHT, 0);
aspectContainer = findViewById(R.id.aspect_container);
surfaceView = findViewById(R.id.surface_playback);
surfaceHolder = surfaceView.getHolder();
surfaceHolder.addCallback(this);
applyAspectRatio();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
videoWidth = intent.getIntExtra(EXTRA_WIDTH, 0);
videoHeight = intent.getIntExtra(EXTRA_HEIGHT, 0);
applyAspectRatio();
attachSurfaceIfReady();
}
@Override
protected void onStart() {
super.onStart();
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
}
@Override
protected void onStop() {
detachSurface();
if (bound) {
unbindService(connection);
bound = false;
receiverService = null;
}
super.onStop();
}
private void applyFullscreen() {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
}
private void applyAspectRatio() {
if (videoWidth > 0 && videoHeight > 0) {
aspectContainer.setAspectRatio(videoWidth, videoHeight);
}
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
if (videoWidth > 0 && videoHeight > 0) {
holder.setFixedSize(videoWidth, videoHeight);
}
attachSurfaceIfReady();
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
attachSurfaceIfReady();
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
detachSurface();
}
private void attachSurfaceIfReady() {
if (!bound || receiverService == null || surfaceHolder.getSurface() == null) {
return;
}
if (!surfaceHolder.getSurface().isValid()) {
return;
}
try {
receiverService.attachVideoSurface(surfaceHolder.getSurface());
} catch (RemoteException ignored) {
}
}
private void detachSurface() {
if (receiverService == null) {
return;
}
try {
receiverService.detachVideoSurface();
} catch (RemoteException ignored) {
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
applyFullscreen();
}
}
}

View File

@@ -0,0 +1,140 @@
package com.foxx.androidcast.receiver;
import android.util.Log;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransportFactory;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/** Server: PIN auth, then forward encoded A/V to decoders. */
public class ReceiverSession {
private static final String TAG = "ReceiverSession";
public interface Listener {
void onStatus(String status);
void onAuthenticated(String senderName, CastSettings remoteSettings);
void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1);
void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data);
void onAudioConfig(int sampleRate, int channels, byte[] csd0);
void onAudioFrame(long ptsUs, byte[] data);
void onDisconnected();
}
private final String pin;
private final CastSettings localSettings;
private final Listener listener;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final AtomicBoolean running = new AtomicBoolean(false);
private CastSession session;
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
this.pin = pin;
this.localSettings = localSettings;
this.listener = listener;
}
public void start() {
if (!running.compareAndSet(false, true)) {
return;
}
executor.execute(this::runServer);
}
public void stop() {
running.set(false);
if (session != null) {
session.close();
session = null;
}
}
private void runServer() {
while (running.get()) {
try {
session = new CastSession(CastTransportFactory.create(localSettings));
session.listen(CastConfig.CAST_PORT);
postStatus("Listening (" + session.getTransport().getModeLabel() + ") port "
+ CastConfig.CAST_PORT);
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
listener.onAuthenticated(hs.senderName, hs.settings);
postStatus("Streaming from " + hs.senderName);
readStream();
} catch (java.io.EOFException e) {
if (running.get()) {
Log.i(TAG, "Connection closed");
postStatus("Sender disconnected");
}
} catch (Exception e) {
if (running.get()) {
Log.e(TAG, "Session error", e);
postStatus(e.getMessage() != null ? e.getMessage() : "Session error");
}
} finally {
if (session != null) {
session.close();
session = null;
}
listener.onDisconnected();
}
if (running.get()) {
postStatus("Waiting for sender…");
}
}
}
private void readStream() throws IOException {
while (running.get()) {
CastProtocol.Message msg;
try {
msg = session.receive(5_000);
} catch (java.io.EOFException e) {
Log.i(TAG, "Sender closed connection");
return;
}
if (msg == null) {
continue;
}
switch (msg.type) {
case CastProtocol.MSG_STREAM_CONFIG:
CastProtocol.StreamConfig vcfg = CastProtocol.parseStreamConfig(msg.payload);
listener.onStreamConfig(vcfg.width, vcfg.height, vcfg.csd0, vcfg.csd1);
break;
case CastProtocol.MSG_VIDEO_FRAME:
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
break;
case CastProtocol.MSG_AUDIO_CONFIG:
CastProtocol.AudioConfig acfg = CastProtocol.parseAudioConfig(msg.payload);
listener.onAudioConfig(acfg.sampleRate, acfg.channels, acfg.csd0);
break;
case CastProtocol.MSG_AUDIO_FRAME:
CastProtocol.AudioFrame af = CastProtocol.parseAudioFrame(msg.payload);
listener.onAudioFrame(af.ptsUs, af.data);
break;
case CastProtocol.MSG_GOODBYE:
postStatus("Sender disconnected");
return;
default:
break;
}
}
}
private void postStatus(String status) {
listener.onStatus(status);
}
}

View File

@@ -0,0 +1,116 @@
package com.foxx.androidcast.receiver;
import android.media.MediaCodec;
import android.media.MediaFormat;
import android.util.Log;
import android.view.Surface;
import com.foxx.androidcast.media.H264Bitstream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Queue;
/** H.264 decoder feeding a Surface for playback. */
public class VideoDecoder {
private static final String TAG = "VideoDecoder";
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
private MediaCodec codec;
private final Queue<PendingFrame> pending = new ArrayDeque<>();
private boolean configured;
private int width;
private int height;
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)));
}
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;
}
decodeFrame(ptsUs, keyFrame, annexB);
}
public void release() {
configured = false;
pending.clear();
if (codec != null) {
try {
codec.stop();
} catch (Exception ignored) {
}
codec.release();
codec = null;
}
}
private void drainPending() {
PendingFrame frame;
while ((frame = pending.poll()) != null) {
decodeFrame(frame.ptsUs, frame.keyFrame, frame.data);
}
}
private void decodeFrame(long ptsUs, boolean keyFrame, byte[] data) {
if (codec == null || data == null || data.length == 0) {
return;
}
try {
int inIndex = codec.dequeueInputBuffer(10_000);
if (inIndex < 0) {
return;
}
ByteBuffer buffer = codec.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();
} 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 static final class PendingFrame {
final long ptsUs;
final boolean keyFrame;
final byte[] data;
PendingFrame(long ptsUs, boolean keyFrame, byte[] data) {
this.ptsUs = ptsUs;
this.keyFrame = keyFrame;
this.data = data;
}
}
}

View File

@@ -0,0 +1,94 @@
package com.foxx.androidcast.sender;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioPlaybackCaptureConfiguration;
import android.media.AudioRecord;
import android.media.projection.MediaProjection;
import android.os.Build;
import android.util.Log;
import com.foxx.androidcast.CastConfig;
/** Captures device playback audio via MediaProjection (API 29+). */
public class AudioCapture {
private static final String TAG = "AudioCapture";
public interface Callback {
void onPcm(byte[] pcm, int length, long ptsUs);
}
private AudioRecord record;
private Thread thread;
private volatile boolean running;
public void start(MediaProjection projection, Callback callback) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.w(TAG, "Audio capture requires API 29+");
return;
}
int channelMask = AudioFormat.CHANNEL_IN_STEREO;
int encoding = AudioFormat.ENCODING_PCM_16BIT;
int minBuf = AudioRecord.getMinBufferSize(CastConfig.AUDIO_SAMPLE_RATE, channelMask, encoding);
int bufferSize = Math.max(minBuf, 8192) * 2;
AudioFormat format = new AudioFormat.Builder()
.setEncoding(encoding)
.setSampleRate(CastConfig.AUDIO_SAMPLE_RATE)
.setChannelMask(channelMask)
.build();
AudioPlaybackCaptureConfiguration capConfig = new AudioPlaybackCaptureConfiguration.Builder(projection)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.build();
record = new AudioRecord.Builder()
.setAudioFormat(format)
.setBufferSizeInBytes(bufferSize)
.setAudioPlaybackCaptureConfig(capConfig)
.build();
if (record.getState() != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord not initialized");
return;
}
running = true;
record.startRecording();
thread = new Thread(() -> readLoop(callback), "AudioCapture");
thread.start();
}
private void readLoop(Callback callback) {
byte[] buf = new byte[4096];
long startNs = System.nanoTime();
while (running) {
int read = record.read(buf, 0, buf.length);
if (read > 0) {
long ptsUs = (System.nanoTime() - startNs) / 1000;
callback.onPcm(buf, read, ptsUs);
}
}
}
public void stop() {
running = false;
if (thread != null) {
try {
thread.join(1000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
if (record != null) {
try {
record.stop();
} catch (Exception ignored) {
}
record.release();
record = null;
}
}
}

View File

@@ -0,0 +1,127 @@
package com.foxx.androidcast.sender;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.util.Log;
import com.foxx.androidcast.CastConfig;
import java.io.IOException;
import java.nio.ByteBuffer;
/** AAC encoder for captured PCM. */
public class AudioEncoder {
private static final String TAG = "AudioEncoder";
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
public interface Callback {
void onConfig(int sampleRate, int channels, byte[] csd0);
void onEncodedFrame(long ptsUs, byte[] data);
}
private MediaCodec codec;
private Callback callback;
private boolean running;
private Thread drainThread;
private long presentationBaseNs = -1;
public void prepare(Callback callback) throws IOException {
this.callback = callback;
MediaFormat format = MediaFormat.createAudioFormat(MIME, CastConfig.AUDIO_SAMPLE_RATE, CastConfig.AUDIO_CHANNEL_COUNT);
format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC);
format.setInteger(MediaFormat.KEY_BIT_RATE, CastConfig.AUDIO_BITRATE);
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384);
codec = MediaCodec.createEncoderByType(MIME);
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
codec.start();
running = true;
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
drainThread.start();
}
public void queuePcm(byte[] pcm, int length, long ptsUs) {
if (codec == null || !running) {
return;
}
try {
int index = codec.dequeueInputBuffer(5_000);
if (index < 0) {
return;
}
ByteBuffer buffer = codec.getInputBuffer(index);
if (buffer == null) {
return;
}
buffer.clear();
buffer.put(pcm, 0, length);
codec.queueInputBuffer(index, 0, length, ptsUs, 0);
} catch (Exception e) {
Log.w(TAG, "queuePcm failed", e);
}
}
public void stop() {
running = false;
if (drainThread != null) {
try {
drainThread.join(1000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
if (codec != null) {
try {
codec.stop();
} catch (Exception ignored) {
}
codec.release();
codec = null;
}
}
private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean configSent = false;
while (running) {
int index = codec.dequeueOutputBuffer(info, 10_000);
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
continue;
}
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
MediaFormat fmt = codec.getOutputFormat();
if (!configSent && callback != null) {
ByteBuffer csd = fmt.getByteBuffer("csd-0");
callback.onConfig(
CastConfig.AUDIO_SAMPLE_RATE,
CastConfig.AUDIO_CHANNEL_COUNT,
csd != null ? toArray(csd) : new byte[0]);
configSent = true;
}
continue;
}
if (index < 0) {
continue;
}
ByteBuffer buffer = codec.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);
buffer.limit(info.offset + info.size);
buffer.get(data);
if (callback != null) {
callback.onEncodedFrame(info.presentationTimeUs, data);
}
}
codec.releaseOutputBuffer(index, false);
}
}
private static byte[] toArray(ByteBuffer buffer) {
byte[] arr = new byte[buffer.remaining()];
buffer.duplicate().get(arr);
return arr;
}
}

View File

@@ -0,0 +1,418 @@
package com.foxx.androidcast.sender;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.hardware.display.DisplayManager;
import android.hardware.display.VirtualDisplay;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
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.DisplayMetrics;
import android.util.Log;
import android.view.Surface;
import android.view.WindowManager;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastResolution;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.ICastSenderService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras;
import com.foxx.androidcast.R;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransportFactory;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
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;
public static final String EXTRA_RESULT_CODE = "result_code";
public static final String EXTRA_RESULT_DATA = "result_data";
public static final String EXTRA_HOST = "host";
public static final String EXTRA_PORT = "port";
public static final String EXTRA_PIN = "pin";
public static final String EXTRA_DEVICE_NAME = "device_name";
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final AtomicBoolean stopping = new AtomicBoolean(false);
private final AtomicBoolean casting = new AtomicBoolean(false);
private MediaProjection projection;
private VirtualDisplay virtualDisplay;
private VideoEncoder videoEncoder;
private AudioEncoder audioEncoder;
private AudioCapture audioCapture;
private CastSession session;
private PowerManager.WakeLock wakeLock;
private Thread ioThread;
private String status = "";
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
@Override
public void registerCallback(ICastStatusCallback callback) {
if (callback != null) {
callbacks.register(callback);
broadcastStatus(status);
}
}
@Override
public void unregisterCallback(ICastStatusCallback callback) {
if (callback != null) {
callbacks.unregister(callback);
}
}
@Override
public String getStatus() {
return status;
}
@Override
public boolean isCasting() {
return casting.get();
}
@Override
public void stopCasting() {
stopSelfSafely();
}
};
@Override
public void onCreate() {
super.onCreate();
CastNotifications.createChannels(this);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
stopSelfSafely();
return START_NOT_STICKY;
}
if (casting.get()) {
return START_STICKY;
}
startSenderForeground(getString(R.string.connecting));
ioThread = new Thread(() -> runCast(intent), "ScreenCastIO");
ioThread.start();
return START_STICKY;
}
private void runCast(Intent intent) {
if (intent == null) {
failAndStop(getString(R.string.error_no_intent));
return;
}
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0);
Intent data = IntentExtras.getParcelableIntent(intent, EXTRA_RESULT_DATA);
String host = intent.getStringExtra(EXTRA_HOST);
int port = intent.getIntExtra(EXTRA_PORT, com.foxx.androidcast.CastConfig.CAST_PORT);
String pin = intent.getStringExtra(EXTRA_PIN);
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
CastSettings settings = IntentExtras.getCastSettings(intent);
if (data == null) {
failAndStop(getString(R.string.error_projection_data));
return;
}
if (host == null || pin == null || settings == null) {
failAndStop(getString(R.string.error_missing_params));
return;
}
acquireWakeLock();
try {
updateStatus(getString(R.string.connecting_to, host));
session = new CastSession(CastTransportFactory.create(settings));
connectWithRetry(host, port);
updateStatus(getString(R.string.authenticating));
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
casting.set(true);
startCaptureOnMainThread(resultCode, new Intent(data), settings);
while (!stopping.get()) {
Thread.sleep(500);
}
} catch (Exception e) {
Log.e(TAG, "Cast failed", e);
failAndStop(e.getMessage() != null ? e.getMessage() : e.toString());
} finally {
releaseAll();
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
}
}
private void connectWithRetry(String host, int port) throws IOException {
IOException last = null;
for (int i = 0; i < CONNECT_RETRIES; i++) {
try {
session.connect(host, port);
return;
} catch (IOException e) {
last = e;
Log.w(TAG, "Connect attempt " + (i + 1) + " failed", e);
try {
Thread.sleep(CONNECT_RETRY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted", ie);
}
}
}
throw new IOException("Could not connect to " + host + ":" + port, last);
}
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
if (Looper.myLooper() != mainHandler.getLooper()) {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<IOException> error = new AtomicReference<>();
mainHandler.post(() -> {
try {
startCaptureOnMainThread(resultCode, data, settings);
} catch (IOException e) {
error.set(e);
} finally {
latch.countDown();
}
});
try {
if (!latch.await(15, TimeUnit.SECONDS)) {
throw new IOException("Capture setup timeout");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted", e);
}
if (error.get() != null) {
throw error.get();
}
return;
}
MediaProjectionManager mgr =
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
projection = mgr.getMediaProjection(resultCode, data);
if (projection == null) {
throw new IOException("MediaProjection unavailable");
}
projection.registerCallback(new MediaProjection.Callback() {
@Override
public void onStop() {
Log.w(TAG, "MediaProjection stopped");
stopping.set(true);
}
}, mainHandler);
DisplayMetrics metrics = new DisplayMetrics();
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);
if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
audioEncoder = new AudioEncoder();
audioEncoder.prepare(new AudioEncoder.Callback() {
private boolean configSent;
@Override
public void onConfig(int sampleRate, int channels, byte[] csd0) {
if (configSent || stopping.get()) {
return;
}
configSent = true;
sendSafe(CastProtocol.MSG_AUDIO_CONFIG,
() -> CastProtocol.audioConfigPayload(sampleRate, channels, csd0));
}
@Override
public void onEncodedFrame(long ptsUs, byte[] frameData) {
sendSafe(CastProtocol.MSG_AUDIO_FRAME,
() -> CastProtocol.audioFramePayload(ptsUs, frameData));
}
});
audioCapture = new AudioCapture();
audioCapture.start(projection, (pcm, length, ptsUs) -> {
if (audioEncoder != null) {
audioEncoder.queuePcm(pcm, length, ptsUs);
}
});
}
videoEncoder.requestKeyframe();
String mode = session.getTransport().getModeLabel();
updateStatus(getString(R.string.casting_active, mode, size.width, size.height));
}
private void send(byte type, byte[] payloadBytes) throws IOException {
if (stopping.get() || session == null) {
return;
}
session.send(type, payloadBytes);
}
private void sendSafe(byte type, PayloadBuilder builder) {
try {
send(type, builder.build());
} catch (IOException e) {
Log.e(TAG, "Send failed", e);
stopping.set(true);
}
}
private interface PayloadBuilder {
byte[] build() throws IOException;
}
private void acquireWakeLock() {
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AndroidCast:Send");
wakeLock.acquire(4 * 60 * 60 * 1000L);
}
private void releaseWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
}
wakeLock = null;
}
private void releaseAll() {
stopping.set(true);
casting.set(false);
if (virtualDisplay != null) {
virtualDisplay.release();
virtualDisplay = null;
}
if (videoEncoder != null) {
videoEncoder.stop();
videoEncoder = null;
}
if (audioCapture != null) {
audioCapture.stop();
audioCapture = null;
}
if (audioEncoder != null) {
audioEncoder.stop();
audioEncoder = null;
}
if (projection != null) {
projection.stop();
projection = null;
}
if (session != null) {
try {
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
} catch (Exception ignored) {
}
session.close();
session = null;
}
releaseWakeLock();
}
private void failAndStop(String message) {
updateStatus(message);
stopping.set(true);
}
private void stopSelfSafely() {
stopping.set(true);
releaseAll();
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
}
private void startSenderForeground(String text) {
android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(CastNotifications.ID_SENDER, n,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
} else {
startForeground(CastNotifications.ID_SENDER, n);
}
}
private void updateStatus(String text) {
status = text;
broadcastStatus(text);
android.app.NotificationManager nm = getSystemService(android.app.NotificationManager.class);
nm.notify(CastNotifications.ID_SENDER,
CastNotifications.sender(this, text, ScreenCastService.class));
}
private void broadcastStatus(String s) {
int n = callbacks.beginBroadcast();
for (int i = 0; i < n; i++) {
try {
callbacks.getBroadcastItem(i).onStatus(s);
} catch (RemoteException ignored) {
}
}
callbacks.finishBroadcast();
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
@Override
public void onDestroy() {
stopping.set(true);
releaseAll();
callbacks.kill();
super.onDestroy();
}
}

View File

@@ -0,0 +1,160 @@
package com.foxx.androidcast.sender;
import android.app.Activity;
import android.content.Intent;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.PlatformCastSupport;
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<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
private ArrayAdapter<String> listAdapter;
private DiscoveryManager.DiscoveredDevice selected;
private EditText pinInput;
private TextView statusText;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sender);
PermissionHelper.request(this);
SettingsUi.bind(this, castSettings);
pinInput = findViewById(R.id.edit_pin);
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.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() + ")");
}
});
discovery = new DiscoveryManager(this);
discovery.setListener(deviceList -> {
devices = deviceList;
List<String> labels = new ArrayList<>();
for (DiscoveryManager.DiscoveredDevice d : deviceList) {
labels.add(d.name + "" + d.host + " [" + d.transport.toUpperCase() + "]");
}
listAdapter.clear();
listAdapter.addAll(labels);
listAdapter.notifyDataSetChanged();
statusText.setText(deviceList.isEmpty()
? getString(R.string.searching)
: getString(R.string.found_receivers, deviceList.size()));
});
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
findViewById(R.id.btn_start_cast).setOnClickListener(v ->
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
}
@Override
protected void onStart() {
super.onStart();
restartDiscovery();
}
@Override
protected void onStop() {
if (discovery != null) {
discovery.stop();
}
super.onStop();
}
private void restartDiscovery() {
if (discovery != null) {
discovery.stop();
}
discovery.startBrowsing();
}
private void startCastFlow() {
if (selected == null) {
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
return;
}
if (!castSettings.getTransport().equals(selected.transport)) {
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
return;
}
String pin = pinInput.getText().toString().trim();
if (TextUtils.isEmpty(pin)) {
Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show();
return;
}
pendingPin = pin;
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION);
}
private String pendingPin;
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != REQUEST_MEDIA_PROJECTION) {
return;
}
if (resultCode != Activity.RESULT_OK || data == null || selected == null) {
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
return;
}
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
if (deviceName == null) {
deviceName = Build.MODEL;
}
Intent serviceIntent = new Intent(this, ScreenCastService.class);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data));
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, selected.host);
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, selected.port);
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, deviceName);
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
Toast.makeText(this, R.string.sender_check_notification, Toast.LENGTH_LONG).show();
finish();
}
}

View File

@@ -0,0 +1,144 @@
package com.foxx.androidcast.sender;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.os.Bundle;
import android.util.Log;
import android.view.Surface;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import java.io.IOException;
import java.nio.ByteBuffer;
/** H.264 encoder using MediaCodec (AVC). */
public class VideoEncoder {
private static final String TAG = "VideoEncoder";
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
public interface Callback {
void onConfig(int width, int height, byte[] csd0, byte[] csd1);
void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] data);
}
private MediaCodec codec;
private Surface inputSurface;
private Callback callback;
private int width;
private int height;
private boolean running;
private Thread drainThread;
public Surface prepare(int width, int height, CastSettings settings, Callback callback) throws IOException {
this.width = width;
this.height = height;
this.callback = callback;
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate());
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps());
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL);
codec = MediaCodec.createEncoderByType(MIME);
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
inputSurface = codec.createInputSurface();
codec.start();
running = true;
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
drainThread.start();
return inputSurface;
}
public void requestKeyframe() {
if (codec == null) {
return;
}
try {
Bundle params = new Bundle();
params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
codec.setParameters(params);
} catch (Exception e) {
Log.w(TAG, "Keyframe request failed", e);
}
}
public void stop() {
running = false;
if (drainThread != null) {
try {
drainThread.join(1500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
if (codec != null) {
try {
codec.stop();
} catch (Exception ignored) {
}
codec.release();
codec = null;
}
if (inputSurface != null) {
inputSurface.release();
inputSurface = null;
}
}
private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean configSent = false;
while (running) {
int index = codec.dequeueOutputBuffer(info, 10_000);
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
continue;
}
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
sendConfig(codec.getOutputFormat(), configSent);
configSent = true;
continue;
}
if (index < 0) {
continue;
}
ByteBuffer buffer = codec.getOutputBuffer(index);
if (buffer != null && info.size > 0) {
byte[] data = new byte[info.size];
buffer.position(info.offset);
buffer.limit(info.offset + info.size);
buffer.get(data);
boolean keyFrame = (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0
&& (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0;
if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0 && callback != null) {
callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data);
}
}
codec.releaseOutputBuffer(index, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
break;
}
}
}
private void sendConfig(MediaFormat format, boolean alreadySent) {
if (alreadySent || callback == null) {
return;
}
ByteBuffer sps = format.getByteBuffer("csd-0");
ByteBuffer pps = format.getByteBuffer("csd-1");
byte[] csd0 = sps != null ? toArray(sps) : new byte[0];
byte[] csd1 = pps != null ? toArray(pps) : new byte[0];
callback.onConfig(width, height, csd0, csd1);
}
private static byte[] toArray(ByteBuffer buffer) {
byte[] arr = new byte[buffer.remaining()];
ByteBuffer dup = buffer.duplicate();
dup.get(arr);
return arr;
}
}

View File

@@ -0,0 +1,67 @@
package com.foxx.androidcast.ui;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.FrameLayout;
/**
* Centers a child and sizes it to fit inside the parent while preserving video aspect ratio.
*/
public class AspectRatioFrameLayout extends FrameLayout {
private float aspectRatio;
public AspectRatioFrameLayout(Context context) {
super(context);
}
public AspectRatioFrameLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public void setAspectRatio(int videoWidth, int videoHeight) {
if (videoWidth <= 0 || videoHeight <= 0) {
aspectRatio = 0f;
} else {
aspectRatio = (float) videoWidth / videoHeight;
}
requestLayout();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (aspectRatio <= 0f || getChildCount() == 0) {
return;
}
int parentW = MeasureSpec.getSize(widthMeasureSpec);
int parentH = MeasureSpec.getSize(heightMeasureSpec);
int childW;
int childH;
float parentRatio = (float) parentW / parentH;
if (parentRatio > aspectRatio) {
childH = parentH;
childW = Math.round(childH * aspectRatio);
} else {
childW = parentW;
childH = Math.round(childW / aspectRatio);
}
int wSpec = MeasureSpec.makeMeasureSpec(childW, MeasureSpec.EXACTLY);
int hSpec = MeasureSpec.makeMeasureSpec(childH, MeasureSpec.EXACTLY);
measureChild(getChildAt(0), wSpec, hSpec);
setMeasuredDimension(parentW, parentH);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
if (getChildCount() == 0) {
return;
}
int parentW = right - left;
int parentH = bottom - top;
int childW = getChildAt(0).getMeasuredWidth();
int childH = getChildAt(0).getMeasuredHeight();
int childLeft = left + (parentW - childW) / 2;
int childTop = top + (parentH - childH) / 2;
getChildAt(0).layout(childLeft, childTop, childLeft + childW, childTop + childH);
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1565C0"
android:pathData="M20,30 L88,54 L20,78 Z" />
<path
android:fillColor="#26A69A"
android:pathData="M30,40 L78,54 L30,68 Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M21,3H3c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h5v2h8v-2h5c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="@string/app_name"
android:textSize="24sp"
android:textStyle="bold" />
<Button
android:id="@+id/btn_send"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="@string/send_screen" />
<Button
android:id="@+id/btn_receive"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/receive_screen" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/main_hint"
android:textSize="14sp" />
</LinearLayout>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<com.foxx.androidcast.ui.AspectRatioFrameLayout
android:id="@+id/aspect_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center">
<SurfaceView
android:id="@+id/surface_playback"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/receiver_title"
android:textSize="18sp"
android:textStyle="bold" />
<include layout="@layout/activity_settings_panel" />
<EditText
android:id="@+id/edit_pin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/pin_hint"
android:inputType="numberPassword" />
<Button
android:id="@+id/btn_start_receiver"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/start_receiver" />
<TextView
android:id="@+id/text_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/waiting_sender" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sender_title"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/searching" />
<include layout="@layout/activity_settings_panel" />
<EditText
android:id="@+id/edit_pin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/pin_hint"
android:inputType="numberPassword"
android:maxLength="16" />
<ListView
android:id="@+id/list_devices"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginTop="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btn_refresh"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="@string/refresh" />
<Button
android:id="@+id/btn_start_cast"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/start_cast" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_title"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_transport" />
<Spinner
android:id="@+id/spinner_transport"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_quality" />
<Spinner
android:id="@+id/spinner_quality"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_resolution" />
<Spinner
android:id="@+id/spinner_resolution"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<CheckBox
android:id="@+id/checkbox_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="true"
android:text="@string/label_audio" />
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0D47A1</color>
</resources>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Android Cast</string>
<string name="send_screen">Send screen (tablet / phone)</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="sender_title">Cast to receiver</string>
<string name="receiver_title">Waiting for cast</string>
<string name="pin_hint">PIN</string>
<string name="refresh">Refresh</string>
<string name="start_cast">Start cast</string>
<string name="searching">Searching for receivers…</string>
<string name="waiting_sender">Waiting for sender…</string>
<string name="settings_title">Stream options</string>
<string name="label_transport">Transport</string>
<string name="label_quality">Quality (bitrate / FPS)</string>
<string name="label_resolution">Capture resolution</string>
<string name="label_audio">Include audio (playback capture, Android 10+)</string>
<string name="label_audio_unavailable">Audio unavailable (requires Android 10+)</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="continue_video_only">Continue (video only)</string>
<string name="transport_tcp">TCP (reliable)</string>
<string name="transport_udp">UDP (best effort)</string>
<string name="quality_low">Low (~0.8 Mbps)</string>
<string name="quality_medium">Medium (~2 Mbps)</string>
<string name="quality_high">High (~4 Mbps)</string>
<string name="resolution_full">Full display</string>
<string name="resolution_75">75% scale</string>
<string name="resolution_half">50% scale</string>
<string name="resolution_720p">Cap height 720p</string>
<string name="transport_mismatch">Transport must match the receiver (TCP/UDP)</string>
<string name="select_receiver">Select a receiver first</string>
<string name="enter_pin">Enter PIN</string>
<string name="capture_denied">Screen capture permission denied</string>
<string name="casting_started">Casting started</string>
<string name="found_receivers">Found %1$d receiver(s)</string>
<string name="receiver_ready">Ready — PIN %1$s · %2$s</string>
<string name="casting_from">Casting from %1$s</string>
<string name="playing_resolution">Playing %1$dx%2$d</string>
<string name="stop">Stop</string>
<string name="start_receiver">Start listening (keeps running in background)</string>
<string name="receiver_starting">Starting receiver service…</string>
<string name="receiver_listening">Listening for cast</string>
<string name="waiting_surface">Connected — open video surface…</string>
<string name="surface_attached">Video surface ready</string>
<string name="connecting">Connecting…</string>
<string name="connecting_to">Connecting to %1$s…</string>
<string name="authenticating">Authenticating…</string>
<string name="casting_active">Casting via %1$s · %2$dx%3$d</string>
<string name="error_projection_data">Screen capture data missing (retry cast)</string>
<string name="error_missing_params">Missing host, PIN, or settings</string>
<string name="error_no_intent">No cast intent</string>
<string name="sender_check_notification">Cast starting — see notification for status</string>
<string name="playback_opened">Fullscreen playback %1$dx%2$d</string>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AndroidCast" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">#1565C0</item>
<item name="colorPrimaryVariant">#0D47A1</item>
<item name="colorOnPrimary">#FFFFFF</item>
<item name="colorSecondary">#26A69A</item>
</style>
<style name="Theme.AndroidCast.Fullscreen" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>

3
build.gradle Normal file
View File

@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.7.3' apply false
}

5
gradle.properties Normal file
View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
# Uncomment and set if default `java` is too new (e.g. JDK 25):
# org.gradle.java.home=/usr/lib/jvm/openjdk-bin-21

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

17
gradlew vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
# Gradle start up script for POSIX generated by Gradle.
APP_HOME=$( cd "${0%/*}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
exec "$JAVACMD" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

17
settings.gradle Normal file
View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "AndroidCast"
include ':app'