From 097c83dc69f6bcbda47355553242561d40526266 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 15 May 2026 16:49:02 +0200 Subject: [PATCH] initial --- .gitignore | 11 + README.md | 86 ++++ app/build.gradle | 38 ++ app/proguard-rules.pro | 1 + app/src/main/AndroidManifest.xml | 58 +++ .../androidcast/ICastReceiverService.aidl | 13 + .../foxx/androidcast/ICastSenderService.aidl | 11 + .../foxx/androidcast/ICastStatusCallback.aidl | 8 + .../java/com/foxx/androidcast/CastConfig.java | 23 + .../foxx/androidcast/CastNotifications.java | 61 +++ .../com/foxx/androidcast/CastResolution.java | 38 ++ .../com/foxx/androidcast/CastSettings.java | 100 +++++ .../com/foxx/androidcast/IntentExtras.java | 29 ++ .../com/foxx/androidcast/MainActivity.java | 31 ++ .../foxx/androidcast/PermissionHelper.java | 42 ++ .../foxx/androidcast/PlatformCastSupport.java | 55 +++ .../java/com/foxx/androidcast/SettingsUi.java | 76 ++++ .../discovery/DiscoveryManager.java | 186 ++++++++ .../foxx/androidcast/media/H264Bitstream.java | 56 +++ .../androidcast/network/CastProtocol.java | 263 +++++++++++ .../foxx/androidcast/network/CastSession.java | 97 ++++ .../androidcast/network/CastTransport.java | 23 + .../network/CastTransportFactory.java | 17 + .../androidcast/network/TcpCastTransport.java | 82 ++++ .../androidcast/network/UdpCastTransport.java | 215 +++++++++ .../androidcast/receiver/AudioDecoder.java | 105 +++++ .../receiver/ReceiverActivity.java | 128 ++++++ .../receiver/ReceiverCastService.java | 321 ++++++++++++++ .../receiver/ReceiverPlaybackActivity.java | 162 +++++++ .../androidcast/receiver/ReceiverSession.java | 140 ++++++ .../androidcast/receiver/VideoDecoder.java | 116 +++++ .../foxx/androidcast/sender/AudioCapture.java | 94 ++++ .../foxx/androidcast/sender/AudioEncoder.java | 127 ++++++ .../androidcast/sender/ScreenCastService.java | 418 ++++++++++++++++++ .../androidcast/sender/SenderActivity.java | 160 +++++++ .../foxx/androidcast/sender/VideoEncoder.java | 144 ++++++ .../ui/AspectRatioFrameLayout.java | 67 +++ .../res/drawable/ic_launcher_foreground.xml | 13 + app/src/main/res/drawable/ic_notification.xml | 10 + app/src/main/res/layout/activity_main.xml | 36 ++ app/src/main/res/layout/activity_playback.xml | 19 + app/src/main/res/layout/activity_receiver.xml | 42 ++ app/src/main/res/layout/activity_sender.xml | 64 +++ .../res/layout/activity_settings_panel.xml | 54 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 56 +++ app/src/main/res/values/themes.xml | 15 + build.gradle | 3 + gradle.properties | 5 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 17 + settings.gradle | 17 + 54 files changed, 3969 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl create mode 100644 app/src/main/aidl/com/foxx/androidcast/ICastSenderService.aidl create mode 100644 app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl create mode 100644 app/src/main/java/com/foxx/androidcast/CastConfig.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastNotifications.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastResolution.java create mode 100644 app/src/main/java/com/foxx/androidcast/CastSettings.java create mode 100644 app/src/main/java/com/foxx/androidcast/IntentExtras.java create mode 100644 app/src/main/java/com/foxx/androidcast/MainActivity.java create mode 100644 app/src/main/java/com/foxx/androidcast/PermissionHelper.java create mode 100644 app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java create mode 100644 app/src/main/java/com/foxx/androidcast/SettingsUi.java create mode 100644 app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java create mode 100644 app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/CastProtocol.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/CastSession.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/CastTransport.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java create mode 100644 app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_notification.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_playback.xml create mode 100644 app/src/main/res/layout/activity_receiver.xml create mode 100644 app/src/main/res/layout/activity_sender.xml create mode 100644 app/src/main/res/layout/activity_settings_panel.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb9d26c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +/build/ +app/build/ +captures/ +.DS_Store +*.apk +*.ap_ +*.dex diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6a62dc --- /dev/null +++ b/README.md @@ -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 Wi‑Fi**. +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**: ~100–300 kb/s — fine for control/metadata only +- **Bluetooth Classic (SPP)**: often ~1–3 Mb/s theoretical — still far below 720p H.264 (~2–8 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 **Wi‑Fi** 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 diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..08f0fa6 --- /dev/null +++ b/app/build.gradle @@ -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' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..b560a09 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# POC — no obfuscation rules yet diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..99ec1b3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl b/app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl new file mode 100644 index 0000000..faed022 --- /dev/null +++ b/app/src/main/aidl/com/foxx/androidcast/ICastReceiverService.aidl @@ -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(); +} diff --git a/app/src/main/aidl/com/foxx/androidcast/ICastSenderService.aidl b/app/src/main/aidl/com/foxx/androidcast/ICastSenderService.aidl new file mode 100644 index 0000000..5256718 --- /dev/null +++ b/app/src/main/aidl/com/foxx/androidcast/ICastSenderService.aidl @@ -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(); +} diff --git a/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl new file mode 100644 index 0000000..4528241 --- /dev/null +++ b/app/src/main/aidl/com/foxx/androidcast/ICastStatusCallback.aidl @@ -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(); +} diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java new file mode 100644 index 0000000..3591e4e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -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 Wi‑Fi MTU with header). */ + public static final int UDP_CHUNK_SIZE = 1200; + + private CastConfig() {} +} diff --git a/app/src/main/java/com/foxx/androidcast/CastNotifications.java b/app/src/main/java/com/foxx/androidcast/CastNotifications.java new file mode 100644 index 0000000..4451ba3 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastNotifications.java @@ -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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastResolution.java b/app/src/main/java/com/foxx/androidcast/CastResolution.java new file mode 100644 index 0000000..cbfebff --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastResolution.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastSettings.java b/app/src/main/java/com/foxx/androidcast/CastSettings.java new file mode 100644 index 0000000..929117e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastSettings.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/IntentExtras.java b/app/src/main/java/com/foxx/androidcast/IntentExtras.java new file mode 100644 index 0000000..36aad5d --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/IntentExtras.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/MainActivity.java b/app/src/main/java/com/foxx/androidcast/MainActivity.java new file mode 100644 index 0000000..c6cd2b0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/MainActivity.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/PermissionHelper.java b/app/src/main/java/com/foxx/androidcast/PermissionHelper.java new file mode 100644 index 0000000..ae47acb --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/PermissionHelper.java @@ -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 missing = missing(activity); + if (!missing.isEmpty()) { + ActivityCompat.requestPermissions( + activity, missing.toArray(new String[0]), REQUEST_CODE); + } + } + + private static List missing(Activity activity) { + List 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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java new file mode 100644 index 0000000..5d50b47 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java @@ -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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/SettingsUi.java b/app/src/main/java/com/foxx/androidcast/SettingsUi.java new file mode 100644 index 0000000..7d798ba --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/SettingsUi.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java new file mode 100644 index 0000000..4603780 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -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 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 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 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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java b/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java new file mode 100644 index 0000000..c828793 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java new file mode 100644 index 0000000..36a9663 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java new file mode 100644 index 0000000..31c67e1 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java @@ -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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java new file mode 100644 index 0000000..3ca205a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java @@ -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(); +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java new file mode 100644 index 0000000..cec3ff8 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java @@ -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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java new file mode 100644 index 0000000..358c06e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java new file mode 100644 index 0000000..dd60460 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java @@ -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 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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java new file mode 100644 index 0000000..66aaa04 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java new file mode 100644 index 0000000..23635fa --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java new file mode 100644 index 0000000..83bd44e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -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 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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java new file mode 100644 index 0000000..a223ea4 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -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(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java new file mode 100644 index 0000000..27ab02e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java new file mode 100644 index 0000000..276da45 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java @@ -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 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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java new file mode 100644 index 0000000..0f95d59 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java new file mode 100644 index 0000000..c629341 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java new file mode 100644 index 0000000..1d54f06 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -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 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 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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java new file mode 100644 index 0000000..f4f5115 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -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 devices = new ArrayList<>(); + private ArrayAdapter 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 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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java new file mode 100644 index 0000000..3770074 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java new file mode 100644 index 0000000..93f48d7 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java @@ -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); + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..413a7f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..56e7c0a --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..a0793cd --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,36 @@ + + + + + +