mirror of
git://f0xx.org/android_cast
synced 2026-07-29 02:59:00 +03:00
initial
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
/.idea/
|
||||
/build/
|
||||
app/build/
|
||||
captures/
|
||||
.DS_Store
|
||||
*.apk
|
||||
*.ap_
|
||||
*.dex
|
||||
86
README.md
Normal file
86
README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Android Cast (POC)
|
||||
|
||||
Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playback audio, encodes **H.264 + AAC**, streams over **TCP or UDP** to a receiver that decodes and plays back.
|
||||
|
||||
## Features
|
||||
|
||||
- UDP broadcast discovery on the local network
|
||||
- TCP or UDP stream transport (selectable; must match on both devices)
|
||||
- PIN auth (SHA-256 hash, default `1234`)
|
||||
- Screen capture → H.264 (AVC) with quality presets and scaled capture resolution
|
||||
- Playback audio capture (API 29+) → AAC-LC
|
||||
- Runtime notification permission (Android 13+)
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install the same APK on two devices on the **same 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
|
||||
38
app/build.gradle
Normal file
38
app/build.gradle
Normal file
@@ -0,0 +1,38 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.foxx.androidcast'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.foxx.androidcast"
|
||||
minSdk 29
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "0.1.0-poc"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
aidl true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||
}
|
||||
1
app/proguard-rules.pro
vendored
Normal file
1
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# POC — no obfuscation rules yet
|
||||
58
app/src/main/AndroidManifest.xml
Normal file
58
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AndroidCast"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".sender.SenderActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".receiver.ReceiverActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".receiver.ReceiverPlaybackActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.AndroidCast.Fullscreen"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
|
||||
|
||||
<service
|
||||
android:name=".sender.ScreenCastService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
|
||||
<service
|
||||
android:name=".receiver.ReceiverCastService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
23
app/src/main/java/com/foxx/androidcast/CastConfig.java
Normal file
23
app/src/main/java/com/foxx/androidcast/CastConfig.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
/** Shared constants for the LAN cast POC. */
|
||||
public final class CastConfig {
|
||||
public static final int DISCOVERY_PORT = 41234;
|
||||
public static final int CAST_PORT = 41235;
|
||||
public static final String DISCOVERY_MAGIC = "ACAST1";
|
||||
public static final String DEFAULT_PIN = "1234";
|
||||
|
||||
public static final String TRANSPORT_TCP = "tcp";
|
||||
public static final String TRANSPORT_UDP = "udp";
|
||||
|
||||
public static final int VIDEO_I_FRAME_INTERVAL = 2;
|
||||
|
||||
public static final int AUDIO_SAMPLE_RATE = 44100;
|
||||
public static final int AUDIO_CHANNEL_COUNT = 2;
|
||||
public static final int AUDIO_BITRATE = 128_000;
|
||||
|
||||
/** Max UDP payload chunk (fits typical Wi‑Fi MTU with header). */
|
||||
public static final int UDP_CHUNK_SIZE = 1200;
|
||||
|
||||
private CastConfig() {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
38
app/src/main/java/com/foxx/androidcast/CastResolution.java
Normal file
38
app/src/main/java/com/foxx/androidcast/CastResolution.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import android.util.DisplayMetrics;
|
||||
|
||||
/** Computes encoder VirtualDisplay size from display metrics and user settings. */
|
||||
public final class CastResolution {
|
||||
private CastResolution() {}
|
||||
|
||||
public static final class Size {
|
||||
public final int width;
|
||||
public final int height;
|
||||
public final int densityDpi;
|
||||
|
||||
public Size(int width, int height, int densityDpi) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.densityDpi = densityDpi;
|
||||
}
|
||||
}
|
||||
|
||||
public static Size compute(DisplayMetrics display, CastSettings settings) {
|
||||
int displayW = display.widthPixels & ~1;
|
||||
int displayH = display.heightPixels & ~1;
|
||||
float scale = settings.getResolutionScale();
|
||||
int encW = Math.max(2, ((int) (displayW * scale)) & ~1);
|
||||
int encH = Math.max(2, ((int) (displayH * scale)) & ~1);
|
||||
|
||||
int maxH = settings.getMaxHeight();
|
||||
if (maxH > 0 && encH > maxH) {
|
||||
float ratio = (float) maxH / encH;
|
||||
encH = maxH & ~1;
|
||||
encW = Math.max(2, ((int) (encW * ratio)) & ~1);
|
||||
}
|
||||
|
||||
int density = Math.max(120, (int) (display.densityDpi * scale));
|
||||
return new Size(encW, encH, density);
|
||||
}
|
||||
}
|
||||
100
app/src/main/java/com/foxx/androidcast/CastSettings.java
Normal file
100
app/src/main/java/com/foxx/androidcast/CastSettings.java
Normal file
@@ -0,0 +1,100 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
|
||||
public class CastSettings implements Serializable {
|
||||
public static final String EXTRA = "cast_settings";
|
||||
|
||||
public enum Quality {
|
||||
LOW(800_000, 20),
|
||||
MEDIUM(2_000_000, 24),
|
||||
HIGH(4_000_000, 30);
|
||||
|
||||
public final int videoBitrate;
|
||||
public final int videoFps;
|
||||
|
||||
Quality(int videoBitrate, int videoFps) {
|
||||
this.videoBitrate = videoBitrate;
|
||||
this.videoFps = videoFps;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture scale relative to physical display (1.0 = native). */
|
||||
public enum Resolution {
|
||||
FULL(1.0f, 0),
|
||||
THREE_QUARTERS(0.75f, 0),
|
||||
HALF(0.5f, 0),
|
||||
HD_720P(1.0f, 720);
|
||||
|
||||
public final float scale;
|
||||
public final int maxHeight;
|
||||
|
||||
Resolution(float scale, int maxHeight) {
|
||||
this.scale = scale;
|
||||
this.maxHeight = maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private String transport = CastConfig.TRANSPORT_TCP;
|
||||
private Quality quality = Quality.MEDIUM;
|
||||
private Resolution resolution = Resolution.FULL;
|
||||
private boolean audioEnabled;
|
||||
|
||||
public CastSettings() {
|
||||
PlatformCastSupport.applyDefaultAudioSetting(this);
|
||||
}
|
||||
|
||||
public String getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
public void setTransport(String transport) {
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
public Quality getQuality() {
|
||||
return quality;
|
||||
}
|
||||
|
||||
public void setQuality(Quality quality) {
|
||||
this.quality = quality;
|
||||
}
|
||||
|
||||
public Resolution getResolution() {
|
||||
return resolution;
|
||||
}
|
||||
|
||||
public void setResolution(Resolution resolution) {
|
||||
this.resolution = resolution;
|
||||
}
|
||||
|
||||
public boolean isAudioEnabled() {
|
||||
return audioEnabled && PlatformCastSupport.isAudioCaptureSupported();
|
||||
}
|
||||
|
||||
public void setAudioEnabled(boolean audioEnabled) {
|
||||
this.audioEnabled = audioEnabled;
|
||||
}
|
||||
|
||||
/** Raw user toggle; may be true on old platforms but {@link #isAudioEnabled()} stays false. */
|
||||
public boolean isAudioRequested() {
|
||||
return audioEnabled;
|
||||
}
|
||||
|
||||
public int getVideoBitrate() {
|
||||
return quality.videoBitrate;
|
||||
}
|
||||
|
||||
public int getVideoFps() {
|
||||
return quality.videoFps;
|
||||
}
|
||||
|
||||
public float getResolutionScale() {
|
||||
return resolution.scale;
|
||||
}
|
||||
|
||||
public int getMaxHeight() {
|
||||
return resolution.maxHeight;
|
||||
}
|
||||
}
|
||||
29
app/src/main/java/com/foxx/androidcast/IntentExtras.java
Normal file
29
app/src/main/java/com/foxx/androidcast/IntentExtras.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
/** API 33+ safe extras helpers. */
|
||||
public final class IntentExtras {
|
||||
private IntentExtras() {}
|
||||
|
||||
public static Intent getParcelableIntent(Intent intent, String key) {
|
||||
if (intent == null) {
|
||||
return null;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return intent.getParcelableExtra(key, Intent.class);
|
||||
}
|
||||
return intent.getParcelableExtra(key);
|
||||
}
|
||||
|
||||
public static CastSettings getCastSettings(Intent intent) {
|
||||
if (intent == null) {
|
||||
return null;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return intent.getSerializableExtra(CastSettings.EXTRA, CastSettings.class);
|
||||
}
|
||||
return (CastSettings) intent.getSerializableExtra(CastSettings.EXTRA);
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/com/foxx/androidcast/MainActivity.java
Normal file
31
app/src/main/java/com/foxx/androidcast/MainActivity.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.foxx.androidcast.receiver.ReceiverActivity;
|
||||
import com.foxx.androidcast.sender.SenderActivity;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
PermissionHelper.request(this);
|
||||
|
||||
Button sendBtn = findViewById(R.id.btn_send);
|
||||
Button recvBtn = findViewById(R.id.btn_receive);
|
||||
sendBtn.setOnClickListener(v ->
|
||||
startActivity(new Intent(this, SenderActivity.class)));
|
||||
recvBtn.setOnClickListener(v ->
|
||||
startActivity(new Intent(this, ReceiverActivity.class)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/com/foxx/androidcast/PermissionHelper.java
Normal file
42
app/src/main/java/com/foxx/androidcast/PermissionHelper.java
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Requests runtime permissions needed for LAN cast POC. */
|
||||
public final class PermissionHelper {
|
||||
public static final int REQUEST_CODE = 1001;
|
||||
|
||||
private PermissionHelper() {}
|
||||
|
||||
public static boolean hasAll(Activity activity) {
|
||||
return missing(activity).isEmpty();
|
||||
}
|
||||
|
||||
public static void request(Activity activity) {
|
||||
List<String> missing = missing(activity);
|
||||
if (!missing.isEmpty()) {
|
||||
ActivityCompat.requestPermissions(
|
||||
activity, missing.toArray(new String[0]), REQUEST_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> missing(Activity activity) {
|
||||
List<String> needed = new ArrayList<>();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(Manifest.permission.POST_NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
return needed;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
76
app/src/main/java/com/foxx/androidcast/SettingsUi.java
Normal file
76
app/src/main/java/com/foxx/androidcast/SettingsUi.java
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.foxx.androidcast.R;
|
||||
|
||||
/** Binds cast option spinners/checkbox to {@link CastSettings}. */
|
||||
public final class SettingsUi {
|
||||
private SettingsUi() {}
|
||||
|
||||
public static void bind(AppCompatActivity activity, CastSettings settings) {
|
||||
PlatformCastSupport.applyDefaultAudioSetting(settings);
|
||||
|
||||
Spinner transport = activity.findViewById(R.id.spinner_transport);
|
||||
Spinner quality = activity.findViewById(R.id.spinner_quality);
|
||||
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
||||
CheckBox audio = activity.findViewById(R.id.checkbox_audio);
|
||||
|
||||
String[] transports = {
|
||||
activity.getString(R.string.transport_tcp),
|
||||
activity.getString(R.string.transport_udp)
|
||||
};
|
||||
transport.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, transports));
|
||||
transport.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||
settings.setTransport(position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {}
|
||||
});
|
||||
|
||||
String[] qualities = {
|
||||
activity.getString(R.string.quality_low),
|
||||
activity.getString(R.string.quality_medium),
|
||||
activity.getString(R.string.quality_high)
|
||||
};
|
||||
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities));
|
||||
quality.setSelection(1);
|
||||
quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||
settings.setQuality(CastSettings.Quality.values()[position]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {}
|
||||
});
|
||||
|
||||
String[] resolutions = {
|
||||
activity.getString(R.string.resolution_full),
|
||||
activity.getString(R.string.resolution_75),
|
||||
activity.getString(R.string.resolution_half),
|
||||
activity.getString(R.string.resolution_720p)
|
||||
};
|
||||
resolution.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, resolutions));
|
||||
resolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||
settings.setResolution(CastSettings.Resolution.values()[position]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {}
|
||||
});
|
||||
|
||||
PlatformCastSupport.bindAudioCheckbox(activity, audio, settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.foxx.androidcast.discovery;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** UDP broadcast discovery of receivers on the same LAN. */
|
||||
public class DiscoveryManager {
|
||||
private static final String TAG = "DiscoveryManager";
|
||||
|
||||
public interface Listener {
|
||||
void onDevicesUpdated(List<DiscoveredDevice> devices);
|
||||
}
|
||||
|
||||
public static final class DiscoveredDevice {
|
||||
public final String name;
|
||||
public final String host;
|
||||
public final int port;
|
||||
public final String transport;
|
||||
public final long lastSeenMs;
|
||||
|
||||
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
|
||||
this.name = name;
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.transport = transport;
|
||||
this.lastSeenMs = lastSeenMs;
|
||||
}
|
||||
}
|
||||
|
||||
private final Context appContext;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private final ConcurrentHashMap<String, DiscoveredDevice> devices = new ConcurrentHashMap<>();
|
||||
|
||||
private Listener listener;
|
||||
private WifiManager.MulticastLock multicastLock;
|
||||
private DatagramSocket socket;
|
||||
|
||||
public DiscoveryManager(Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
public void setListener(Listener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void startBrowsing() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(this::browseLoop);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running.set(false);
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
releaseMulticastLock();
|
||||
devices.clear();
|
||||
}
|
||||
|
||||
public void startAnnouncing(String deviceName, int port, String transport) {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> announceLoop(deviceName, port, transport));
|
||||
}
|
||||
|
||||
private void browseLoop() {
|
||||
acquireMulticastLock();
|
||||
try (DatagramSocket sock = new DatagramSocket(null)) {
|
||||
socket = sock;
|
||||
sock.setReuseAddress(true);
|
||||
sock.bind(new InetSocketAddress(CastConfig.DISCOVERY_PORT));
|
||||
sock.setBroadcast(true);
|
||||
byte[] buf = new byte[1024];
|
||||
while (running.get()) {
|
||||
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||
sock.receive(packet);
|
||||
handlePacket(packet);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (running.get()) {
|
||||
Log.e(TAG, "Browse failed", e);
|
||||
}
|
||||
} finally {
|
||||
releaseMulticastLock();
|
||||
}
|
||||
}
|
||||
|
||||
private void announceLoop(String deviceName, int port, String transport) {
|
||||
acquireMulticastLock();
|
||||
try (DatagramSocket sock = new DatagramSocket()) {
|
||||
socket = sock;
|
||||
sock.setBroadcast(true);
|
||||
while (running.get()) {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
||||
json.put("name", deviceName);
|
||||
json.put("port", port);
|
||||
json.put("transport", transport);
|
||||
byte[] data = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(data, data.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||
sock.send(packet);
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (running.get()) {
|
||||
Log.e(TAG, "Announce failed", e);
|
||||
}
|
||||
} finally {
|
||||
releaseMulticastLock();
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePacket(DatagramPacket packet) {
|
||||
try {
|
||||
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
|
||||
JSONObject json = new JSONObject(text);
|
||||
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
|
||||
return;
|
||||
}
|
||||
String name = json.optString("name", "Unknown");
|
||||
int port = json.optInt("port", CastConfig.CAST_PORT);
|
||||
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
|
||||
String host = packet.getAddress().getHostAddress();
|
||||
String key = host + ":" + port;
|
||||
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, System.currentTimeMillis());
|
||||
devices.put(key, device);
|
||||
notifyListener();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListener() {
|
||||
if (listener == null) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
List<DiscoveredDevice> fresh = new ArrayList<>();
|
||||
for (DiscoveredDevice d : devices.values()) {
|
||||
if (now - d.lastSeenMs < 10_000) {
|
||||
fresh.add(d);
|
||||
}
|
||||
}
|
||||
mainHandler.post(() -> listener.onDevicesUpdated(fresh));
|
||||
}
|
||||
|
||||
private void acquireMulticastLock() {
|
||||
WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE);
|
||||
if (wifi != null) {
|
||||
multicastLock = wifi.createMulticastLock("androidcast-discovery");
|
||||
multicastLock.setReferenceCounted(true);
|
||||
multicastLock.acquire();
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseMulticastLock() {
|
||||
if (multicastLock != null && multicastLock.isHeld()) {
|
||||
multicastLock.release();
|
||||
}
|
||||
multicastLock = null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
263
app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
Normal file
263
app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
Normal file
@@ -0,0 +1,263 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/** Length-prefixed binary protocol (TCP) and payload format (shared with UDP). */
|
||||
public final class CastProtocol {
|
||||
public static final byte MSG_HELLO = 1;
|
||||
public static final byte MSG_AUTH = 2;
|
||||
public static final byte MSG_AUTH_OK = 3;
|
||||
public static final byte MSG_AUTH_FAIL = 4;
|
||||
public static final byte MSG_STREAM_CONFIG = 5;
|
||||
public static final byte MSG_VIDEO_FRAME = 6;
|
||||
public static final byte MSG_GOODBYE = 7;
|
||||
public static final byte MSG_AUDIO_CONFIG = 8;
|
||||
public static final byte MSG_AUDIO_FRAME = 9;
|
||||
public static final byte MSG_CAST_SETTINGS = 10;
|
||||
|
||||
private CastProtocol() {}
|
||||
|
||||
public static void writeMessage(DataOutputStream out, byte type, byte[] payload) throws IOException {
|
||||
out.writeByte(type);
|
||||
int len = payload == null ? 0 : payload.length;
|
||||
out.writeInt(len);
|
||||
if (len > 0) {
|
||||
out.write(payload);
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
public static Message readMessage(DataInputStream in) throws IOException {
|
||||
byte type = in.readByte();
|
||||
int len = in.readInt();
|
||||
if (len < 0 || len > 4 * 1024 * 1024) {
|
||||
throw new IOException("Invalid payload length: " + len);
|
||||
}
|
||||
byte[] payload = len == 0 ? new byte[0] : new byte[len];
|
||||
if (len > 0) {
|
||||
in.readFully(payload);
|
||||
}
|
||||
return new Message(type, payload);
|
||||
}
|
||||
|
||||
public static byte[] helloPayload(String deviceName) {
|
||||
return deviceName.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static byte[] authPayload(String pin) {
|
||||
return sha256(pin).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static byte[] castSettingsPayload(CastSettings settings) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.writeUTF(settings.getTransport());
|
||||
dos.writeInt(settings.getQuality().ordinal());
|
||||
dos.writeInt(settings.getResolution().ordinal());
|
||||
dos.writeByte(settings.isAudioEnabled() ? 1 : 0);
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static CastSettings parseCastSettings(byte[] payload) throws IOException {
|
||||
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||
CastSettings settings = new CastSettings();
|
||||
settings.setTransport(dis.readUTF());
|
||||
int q = dis.readInt();
|
||||
int r = dis.readInt();
|
||||
settings.setQuality(CastSettings.Quality.values()[q]);
|
||||
settings.setResolution(CastSettings.Resolution.values()[r]);
|
||||
settings.setAudioEnabled(dis.readByte() != 0);
|
||||
return settings;
|
||||
}
|
||||
|
||||
public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.writeInt(width);
|
||||
dos.writeInt(height);
|
||||
writeBytes(dos, csd0);
|
||||
writeBytes(dos, csd1);
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static byte[] audioConfigPayload(int sampleRate, int channels, byte[] csd0) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.writeInt(sampleRate);
|
||||
dos.writeInt(channels);
|
||||
writeBytes(dos, csd0);
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static byte[] videoFramePayload(long ptsUs, boolean keyFrame, byte[] data) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.writeLong(ptsUs);
|
||||
dos.writeByte(keyFrame ? 1 : 0);
|
||||
writeBytes(dos, data);
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static byte[] audioFramePayload(long ptsUs, byte[] data) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.writeLong(ptsUs);
|
||||
writeBytes(dos, data);
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static StreamConfig parseStreamConfig(byte[] payload) throws IOException {
|
||||
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||
int width = dis.readInt();
|
||||
int height = dis.readInt();
|
||||
byte[] csd0 = readBytes(dis);
|
||||
byte[] csd1 = readBytes(dis);
|
||||
return new StreamConfig(width, height, csd0, csd1);
|
||||
}
|
||||
|
||||
public static AudioConfig parseAudioConfig(byte[] payload) throws IOException {
|
||||
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||
int sampleRate = dis.readInt();
|
||||
int channels = dis.readInt();
|
||||
byte[] csd0 = readBytes(dis);
|
||||
return new AudioConfig(sampleRate, channels, csd0);
|
||||
}
|
||||
|
||||
public static VideoFrame parseVideoFrame(byte[] payload) throws IOException {
|
||||
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||
long ptsUs = dis.readLong();
|
||||
boolean keyFrame = dis.readByte() != 0;
|
||||
byte[] data = readBytes(dis);
|
||||
return new VideoFrame(ptsUs, keyFrame, data);
|
||||
}
|
||||
|
||||
public static AudioFrame parseAudioFrame(byte[] payload) throws IOException {
|
||||
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||
long ptsUs = dis.readLong();
|
||||
byte[] data = readBytes(dis);
|
||||
return new AudioFrame(ptsUs, data);
|
||||
}
|
||||
|
||||
public static String payloadAsUtf8(byte[] payload) {
|
||||
return new String(payload, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static boolean pinMatches(byte[] authHashUtf8, String pin) {
|
||||
String expected = sha256(pin);
|
||||
String received = new String(authHashUtf8, StandardCharsets.UTF_8);
|
||||
return expected.equals(received);
|
||||
}
|
||||
|
||||
public static boolean transportMatches(CastSettings local, CastSettings remote) {
|
||||
return local.getTransport().equals(remote.getTransport());
|
||||
}
|
||||
|
||||
private static void writeBytes(DataOutputStream dos, byte[] data) throws IOException {
|
||||
if (data == null) {
|
||||
dos.writeInt(0);
|
||||
return;
|
||||
}
|
||||
dos.writeInt(data.length);
|
||||
if (data.length > 0) {
|
||||
dos.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readBytes(DataInputStream dis) throws IOException {
|
||||
int len = dis.readInt();
|
||||
if (len == 0) {
|
||||
return new byte[0];
|
||||
}
|
||||
if (len < 0 || len > 512 * 1024) {
|
||||
throw new IOException("Invalid blob length");
|
||||
}
|
||||
byte[] buf = new byte[len];
|
||||
dis.readFully(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static String sha256(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Message {
|
||||
public final byte type;
|
||||
public final byte[] payload;
|
||||
|
||||
public Message(byte type, byte[] payload) {
|
||||
this.type = type;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class StreamConfig {
|
||||
public final int width;
|
||||
public final int height;
|
||||
public final byte[] csd0;
|
||||
public final byte[] csd1;
|
||||
|
||||
public StreamConfig(int width, int height, byte[] csd0, byte[] csd1) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.csd0 = csd0;
|
||||
this.csd1 = csd1;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class AudioConfig {
|
||||
public final int sampleRate;
|
||||
public final int channels;
|
||||
public final byte[] csd0;
|
||||
|
||||
public AudioConfig(int sampleRate, int channels, byte[] csd0) {
|
||||
this.sampleRate = sampleRate;
|
||||
this.channels = channels;
|
||||
this.csd0 = csd0;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class VideoFrame {
|
||||
public final long ptsUs;
|
||||
public final boolean keyFrame;
|
||||
public final byte[] data;
|
||||
|
||||
public VideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||
this.ptsUs = ptsUs;
|
||||
this.keyFrame = keyFrame;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class AudioFrame {
|
||||
public final long ptsUs;
|
||||
public final byte[] data;
|
||||
|
||||
public AudioFrame(long ptsUs, byte[] data) {
|
||||
this.ptsUs = ptsUs;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Best-effort message transport over UDP with fragmentation. */
|
||||
public class UdpCastTransport implements CastTransport {
|
||||
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
||||
private static final int HEADER = 16;
|
||||
|
||||
private DatagramSocket socket;
|
||||
private InetSocketAddress peer;
|
||||
private int nextMessageId = 1;
|
||||
|
||||
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void listen(int port) throws IOException {
|
||||
socket = new DatagramSocket(null);
|
||||
socket.setReuseAddress(true);
|
||||
socket.bind(new InetSocketAddress(port));
|
||||
socket.setSoTimeout(500);
|
||||
// peer set on first received packet
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(String host, int port) throws IOException {
|
||||
socket = new DatagramSocket();
|
||||
peer = new InetSocketAddress(host, port);
|
||||
socket.setSoTimeout(500);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(byte type, byte[] payload) throws IOException {
|
||||
if (peer == null) {
|
||||
throw new IOException("UDP peer not set");
|
||||
}
|
||||
byte[] body = payload == null ? new byte[0] : payload;
|
||||
int messageId = nextMessageId++;
|
||||
int chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
|
||||
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
|
||||
for (int i = 0; i < fragments; i++) {
|
||||
int offset = i * chunkSize;
|
||||
int len = Math.min(chunkSize, body.length - offset);
|
||||
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
|
||||
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
|
||||
socket.send(dp);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
|
||||
socket.setSoTimeout(remaining);
|
||||
try {
|
||||
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
|
||||
DatagramPacket dp = new DatagramPacket(buf, buf.length);
|
||||
socket.receive(dp);
|
||||
if (peer == null) {
|
||||
peer = new InetSocketAddress(dp.getAddress(), dp.getPort());
|
||||
}
|
||||
CastProtocol.Message msg = parsePacket(dp.getData(), dp.getLength());
|
||||
if (msg != null) {
|
||||
return msg;
|
||||
}
|
||||
} catch (SocketTimeoutException ignored) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
|
||||
if (length < HEADER) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (data[i] != MAGIC[i]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
byte type = data[4];
|
||||
int messageId = readInt(data, 5);
|
||||
int fragIndex = readShort(data, 9);
|
||||
int fragCount = readShort(data, 11);
|
||||
int totalLen = readInt(data, 13);
|
||||
int payloadLen = length - HEADER;
|
||||
byte[] chunk = new byte[payloadLen];
|
||||
System.arraycopy(data, HEADER, chunk, 0, payloadLen);
|
||||
|
||||
if (fragCount <= 1) {
|
||||
if (totalLen != payloadLen) {
|
||||
byte[] trimmed = new byte[totalLen];
|
||||
System.arraycopy(chunk, 0, trimmed, 0, Math.min(totalLen, payloadLen));
|
||||
return new CastProtocol.Message(type, trimmed);
|
||||
}
|
||||
return new CastProtocol.Message(type, chunk);
|
||||
}
|
||||
|
||||
Reassembly r = pending.get(messageId);
|
||||
if (r == null || r.fragCount != fragCount || r.totalLen != totalLen) {
|
||||
r = new Reassembly(fragCount, totalLen);
|
||||
pending.put(messageId, r);
|
||||
}
|
||||
r.put(fragIndex, chunk, payloadLen);
|
||||
if (!r.complete()) {
|
||||
return null;
|
||||
}
|
||||
pending.remove(messageId);
|
||||
return new CastProtocol.Message(type, r.assemble());
|
||||
}
|
||||
|
||||
private static byte[] buildPacket(
|
||||
byte type, int messageId, int fragIndex, int fragCount,
|
||||
byte[] body, int offset, int len, int totalLen) {
|
||||
byte[] packet = new byte[HEADER + len];
|
||||
System.arraycopy(MAGIC, 0, packet, 0, 4);
|
||||
packet[4] = type;
|
||||
writeInt(packet, 5, messageId);
|
||||
writeShort(packet, 9, fragIndex);
|
||||
writeShort(packet, 11, fragCount);
|
||||
writeInt(packet, 13, totalLen);
|
||||
System.arraycopy(body, offset, packet, HEADER, len);
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static int readInt(byte[] b, int off) {
|
||||
return ((b[off] & 0xFF) << 24) | ((b[off + 1] & 0xFF) << 16)
|
||||
| ((b[off + 2] & 0xFF) << 8) | (b[off + 3] & 0xFF);
|
||||
}
|
||||
|
||||
private static int readShort(byte[] b, int off) {
|
||||
return ((b[off] & 0xFF) << 8) | (b[off + 1] & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeInt(byte[] b, int off, int v) {
|
||||
b[off] = (byte) (v >> 24);
|
||||
b[off + 1] = (byte) (v >> 16);
|
||||
b[off + 2] = (byte) (v >> 8);
|
||||
b[off + 3] = (byte) v;
|
||||
}
|
||||
|
||||
private static void writeShort(byte[] b, int off, int v) {
|
||||
b[off] = (byte) (v >> 8);
|
||||
b[off + 1] = (byte) v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModeLabel() {
|
||||
return "UDP";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
private static final class Reassembly {
|
||||
final int fragCount;
|
||||
final int totalLen;
|
||||
final byte[][] parts;
|
||||
final int[] sizes;
|
||||
int received;
|
||||
|
||||
Reassembly(int fragCount, int totalLen) {
|
||||
this.fragCount = fragCount;
|
||||
this.totalLen = totalLen;
|
||||
this.parts = new byte[fragCount][];
|
||||
this.sizes = new int[fragCount];
|
||||
}
|
||||
|
||||
void put(int index, byte[] data, int len) {
|
||||
if (index < 0 || index >= fragCount || parts[index] != null) {
|
||||
return;
|
||||
}
|
||||
byte[] copy = new byte[len];
|
||||
System.arraycopy(data, 0, copy, 0, len);
|
||||
parts[index] = copy;
|
||||
sizes[index] = len;
|
||||
received++;
|
||||
}
|
||||
|
||||
boolean complete() {
|
||||
return received == fragCount;
|
||||
}
|
||||
|
||||
byte[] assemble() {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(totalLen);
|
||||
for (int i = 0; i < fragCount; i++) {
|
||||
bos.write(parts[i], 0, sizes[i]);
|
||||
}
|
||||
byte[] out = bos.toByteArray();
|
||||
if (out.length > totalLen) {
|
||||
byte[] trimmed = new byte[totalLen];
|
||||
System.arraycopy(out, 0, trimmed, 0, totalLen);
|
||||
return trimmed;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.foxx.androidcast.receiver;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.RemoteCallbackList;
|
||||
import android.os.RemoteException;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastNotifications;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.ICastReceiverService;
|
||||
import com.foxx.androidcast.ICastStatusCallback;
|
||||
import com.foxx.androidcast.IntentExtras;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
|
||||
/** Foreground receiver: discovery, TCP/UDP listen, decode A/V (survives rotation). */
|
||||
public class ReceiverCastService extends Service {
|
||||
private static final String TAG = "ReceiverCastService";
|
||||
public static final String ACTION_START = "com.foxx.androidcast.RECEIVER_START";
|
||||
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_RECV";
|
||||
public static final String EXTRA_PIN = "pin";
|
||||
|
||||
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private DiscoveryManager discovery;
|
||||
private ReceiverSession session;
|
||||
private VideoDecoder videoDecoder;
|
||||
private AudioDecoder audioDecoder;
|
||||
private Surface attachedSurface;
|
||||
private String status = "";
|
||||
private CastSettings settings = new CastSettings();
|
||||
private boolean started;
|
||||
|
||||
private int pendingWidth;
|
||||
private int pendingHeight;
|
||||
private byte[] pendingCsd0;
|
||||
private byte[] pendingCsd1;
|
||||
private boolean hasPendingConfig;
|
||||
|
||||
private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() {
|
||||
@Override
|
||||
public void registerCallback(ICastStatusCallback callback) {
|
||||
if (callback != null) {
|
||||
callbacks.register(callback);
|
||||
broadcastStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregisterCallback(ICastStatusCallback callback) {
|
||||
if (callback != null) {
|
||||
callbacks.unregister(callback);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attachVideoSurface(Surface surface) {
|
||||
mainHandler.post(() -> attachSurface(surface));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void detachVideoSurface() {
|
||||
mainHandler.post(ReceiverCastService.this::detachSurfaceOnly);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopReceiving() {
|
||||
stopSelfSafely();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
CastNotifications.createChannels(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
stopSelfSafely();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (intent != null && ACTION_START.equals(intent.getAction())) {
|
||||
String pin = intent.getStringExtra(EXTRA_PIN);
|
||||
if (pin == null || pin.isEmpty()) {
|
||||
pin = CastConfig.DEFAULT_PIN;
|
||||
}
|
||||
CastSettings fromIntent = IntentExtras.getCastSettings(intent);
|
||||
if (fromIntent != null) {
|
||||
settings = fromIntent;
|
||||
}
|
||||
startReceiving(pin);
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void startReceiving(String pin) {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
startForeground(CastNotifications.ID_RECEIVER,
|
||||
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
|
||||
|
||||
videoDecoder = new VideoDecoder();
|
||||
audioDecoder = new AudioDecoder();
|
||||
|
||||
discovery = new DiscoveryManager(this);
|
||||
discovery.startAnnouncing(android.os.Build.MODEL, CastConfig.CAST_PORT, settings.getTransport());
|
||||
|
||||
session = new ReceiverSession(pin, settings, new ReceiverSession.Listener() {
|
||||
@Override
|
||||
public void onStatus(String s) {
|
||||
updateStatus(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthenticated(String senderName, CastSettings remoteSettings) {
|
||||
updateStatus(getString(R.string.casting_from, senderName));
|
||||
broadcastAuthenticated(senderName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1) {
|
||||
mainHandler.post(() -> onStreamConfigReceived(width, height, csd0, csd1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioConfig(int sampleRate, int channels, byte[] csd0) {
|
||||
mainHandler.post(() -> {
|
||||
try {
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.configure(sampleRate, channels, csd0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
updateStatus("Audio: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.queueFrame(ptsUs, data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
broadcastDisconnected();
|
||||
updateStatus(getString(R.string.waiting_sender));
|
||||
}
|
||||
});
|
||||
session.start();
|
||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
||||
}
|
||||
|
||||
private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) {
|
||||
pendingWidth = width;
|
||||
pendingHeight = height;
|
||||
pendingCsd0 = csd0;
|
||||
pendingCsd1 = csd1;
|
||||
hasPendingConfig = true;
|
||||
launchPlaybackActivity(width, height);
|
||||
tryConfigureDecoder();
|
||||
}
|
||||
|
||||
private void launchPlaybackActivity(int width, int height) {
|
||||
Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
intent.putExtra(ReceiverPlaybackActivity.EXTRA_WIDTH, width);
|
||||
intent.putExtra(ReceiverPlaybackActivity.EXTRA_HEIGHT, height);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void tryConfigureDecoder() {
|
||||
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (videoDecoder == null) {
|
||||
videoDecoder = new VideoDecoder();
|
||||
}
|
||||
videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface);
|
||||
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
|
||||
broadcastStreamStarted(pendingWidth, pendingHeight);
|
||||
hasPendingConfig = false;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Video configure failed", e);
|
||||
updateStatus("Video: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void attachSurface(Surface surface) {
|
||||
attachedSurface = surface;
|
||||
tryConfigureDecoder();
|
||||
}
|
||||
|
||||
private void detachSurfaceOnly() {
|
||||
attachedSurface = null;
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.release();
|
||||
videoDecoder = new VideoDecoder();
|
||||
}
|
||||
hasPendingConfig = pendingWidth > 0 && pendingHeight > 0;
|
||||
}
|
||||
|
||||
private void updateStatus(String s) {
|
||||
status = s;
|
||||
broadcastStatus(s);
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
nm.notify(CastNotifications.ID_RECEIVER,
|
||||
CastNotifications.receiver(this, s, ReceiverCastService.class));
|
||||
}
|
||||
|
||||
private void broadcastStatus(String s) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onStatus(s);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
|
||||
private void broadcastAuthenticated(String name) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onAuthenticated(name);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
|
||||
private void broadcastStreamStarted(int w, int h) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onStreamStarted(w, h);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
|
||||
private void broadcastDisconnected() {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onDisconnected();
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
|
||||
private void releaseAll() {
|
||||
started = false;
|
||||
hasPendingConfig = false;
|
||||
if (discovery != null) {
|
||||
discovery.stop();
|
||||
discovery = null;
|
||||
}
|
||||
if (session != null) {
|
||||
session.stop();
|
||||
session = null;
|
||||
}
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.release();
|
||||
videoDecoder = null;
|
||||
}
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.release();
|
||||
audioDecoder = null;
|
||||
}
|
||||
attachedSurface = null;
|
||||
}
|
||||
|
||||
private void stopSelfSafely() {
|
||||
releaseAll();
|
||||
callbacks.kill();
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseAll();
|
||||
callbacks.kill();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.foxx.androidcast.receiver;
|
||||
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaFormat;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
|
||||
import com.foxx.androidcast.media.H264Bitstream;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Queue;
|
||||
|
||||
/** H.264 decoder feeding a Surface for playback. */
|
||||
public class VideoDecoder {
|
||||
private static final String TAG = "VideoDecoder";
|
||||
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||
|
||||
private MediaCodec codec;
|
||||
private final Queue<PendingFrame> pending = new ArrayDeque<>();
|
||||
private boolean configured;
|
||||
private int width;
|
||||
private int height;
|
||||
|
||||
public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface) throws IOException {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
|
||||
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height);
|
||||
if (csd0 != null && csd0.length > 0) {
|
||||
format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0)));
|
||||
}
|
||||
if (csd1 != null && csd1.length > 0) {
|
||||
format.setByteBuffer("csd-1", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd1)));
|
||||
}
|
||||
codec = MediaCodec.createDecoderByType(MIME);
|
||||
codec.configure(format, surface, null, 0);
|
||||
codec.start();
|
||||
configured = true;
|
||||
drainPending();
|
||||
}
|
||||
|
||||
public synchronized void queueFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||
byte[] annexB = H264Bitstream.toAnnexB(data);
|
||||
if (!configured) {
|
||||
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
|
||||
return;
|
||||
}
|
||||
decodeFrame(ptsUs, keyFrame, annexB);
|
||||
}
|
||||
|
||||
public void release() {
|
||||
configured = false;
|
||||
pending.clear();
|
||||
if (codec != null) {
|
||||
try {
|
||||
codec.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
codec.release();
|
||||
codec = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void drainPending() {
|
||||
PendingFrame frame;
|
||||
while ((frame = pending.poll()) != null) {
|
||||
decodeFrame(frame.ptsUs, frame.keyFrame, frame.data);
|
||||
}
|
||||
}
|
||||
|
||||
private void decodeFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||
if (codec == null || data == null || data.length == 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int inIndex = codec.dequeueInputBuffer(10_000);
|
||||
if (inIndex < 0) {
|
||||
return;
|
||||
}
|
||||
ByteBuffer buffer = codec.getInputBuffer(inIndex);
|
||||
if (buffer == null) {
|
||||
return;
|
||||
}
|
||||
buffer.clear();
|
||||
buffer.put(data);
|
||||
int flags = keyFrame ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
|
||||
codec.queueInputBuffer(inIndex, 0, data.length, ptsUs, flags);
|
||||
drainOutput();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Decode error", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void drainOutput() {
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
int outIndex = codec.dequeueOutputBuffer(info, 0);
|
||||
while (outIndex >= 0) {
|
||||
codec.releaseOutputBuffer(outIndex, true);
|
||||
outIndex = codec.dequeueOutputBuffer(info, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PendingFrame {
|
||||
final long ptsUs;
|
||||
final boolean keyFrame;
|
||||
final byte[] data;
|
||||
|
||||
PendingFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||
this.ptsUs = ptsUs;
|
||||
this.keyFrame = keyFrame;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
Normal file
127
app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java
Normal file
@@ -0,0 +1,127 @@
|
||||
package com.foxx.androidcast.sender;
|
||||
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** AAC encoder for captured PCM. */
|
||||
public class AudioEncoder {
|
||||
private static final String TAG = "AudioEncoder";
|
||||
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
|
||||
|
||||
public interface Callback {
|
||||
void onConfig(int sampleRate, int channels, byte[] csd0);
|
||||
|
||||
void onEncodedFrame(long ptsUs, byte[] data);
|
||||
}
|
||||
|
||||
private MediaCodec codec;
|
||||
private Callback callback;
|
||||
private boolean running;
|
||||
private Thread drainThread;
|
||||
private long presentationBaseNs = -1;
|
||||
|
||||
public void prepare(Callback callback) throws IOException {
|
||||
this.callback = callback;
|
||||
MediaFormat format = MediaFormat.createAudioFormat(MIME, CastConfig.AUDIO_SAMPLE_RATE, CastConfig.AUDIO_CHANNEL_COUNT);
|
||||
format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC);
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, CastConfig.AUDIO_BITRATE);
|
||||
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384);
|
||||
|
||||
codec = MediaCodec.createEncoderByType(MIME);
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
||||
codec.start();
|
||||
running = true;
|
||||
drainThread = new Thread(this::drainLoop, "AudioEncoderDrain");
|
||||
drainThread.start();
|
||||
}
|
||||
|
||||
public void queuePcm(byte[] pcm, int length, long ptsUs) {
|
||||
if (codec == null || !running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int index = codec.dequeueInputBuffer(5_000);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
ByteBuffer buffer = codec.getInputBuffer(index);
|
||||
if (buffer == null) {
|
||||
return;
|
||||
}
|
||||
buffer.clear();
|
||||
buffer.put(pcm, 0, length);
|
||||
codec.queueInputBuffer(index, 0, length, ptsUs, 0);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "queuePcm failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
if (drainThread != null) {
|
||||
try {
|
||||
drainThread.join(1000);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
if (codec != null) {
|
||||
try {
|
||||
codec.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
codec.release();
|
||||
codec = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void drainLoop() {
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean configSent = false;
|
||||
while (running) {
|
||||
int index = codec.dequeueOutputBuffer(info, 10_000);
|
||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||
continue;
|
||||
}
|
||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
MediaFormat fmt = codec.getOutputFormat();
|
||||
if (!configSent && callback != null) {
|
||||
ByteBuffer csd = fmt.getByteBuffer("csd-0");
|
||||
callback.onConfig(
|
||||
CastConfig.AUDIO_SAMPLE_RATE,
|
||||
CastConfig.AUDIO_CHANNEL_COUNT,
|
||||
csd != null ? toArray(csd) : new byte[0]);
|
||||
configSent = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
ByteBuffer buffer = codec.getOutputBuffer(index);
|
||||
if (buffer != null && info.size > 0 && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
|
||||
byte[] data = new byte[info.size];
|
||||
buffer.position(info.offset);
|
||||
buffer.limit(info.offset + info.size);
|
||||
buffer.get(data);
|
||||
if (callback != null) {
|
||||
callback.onEncodedFrame(info.presentationTimeUs, data);
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(index, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] toArray(ByteBuffer buffer) {
|
||||
byte[] arr = new byte[buffer.remaining()];
|
||||
buffer.duplicate().get(arr);
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package com.foxx.androidcast.sender;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.hardware.display.VirtualDisplay;
|
||||
import android.media.projection.MediaProjection;
|
||||
import android.media.projection.MediaProjectionManager;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.PowerManager;
|
||||
import android.os.RemoteCallbackList;
|
||||
import android.os.RemoteException;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.foxx.androidcast.CastNotifications;
|
||||
import com.foxx.androidcast.CastResolution;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.ICastSenderService;
|
||||
import com.foxx.androidcast.ICastStatusCallback;
|
||||
import com.foxx.androidcast.IntentExtras;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.CastTransportFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
|
||||
public class ScreenCastService extends Service {
|
||||
private static final String TAG = "ScreenCastService";
|
||||
private static final int CONNECT_RETRIES = 5;
|
||||
private static final long CONNECT_RETRY_MS = 800;
|
||||
|
||||
public static final String EXTRA_RESULT_CODE = "result_code";
|
||||
public static final String EXTRA_RESULT_DATA = "result_data";
|
||||
public static final String EXTRA_HOST = "host";
|
||||
public static final String EXTRA_PORT = "port";
|
||||
public static final String EXTRA_PIN = "pin";
|
||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
|
||||
|
||||
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final AtomicBoolean stopping = new AtomicBoolean(false);
|
||||
private final AtomicBoolean casting = new AtomicBoolean(false);
|
||||
|
||||
private MediaProjection projection;
|
||||
private VirtualDisplay virtualDisplay;
|
||||
private VideoEncoder videoEncoder;
|
||||
private AudioEncoder audioEncoder;
|
||||
private AudioCapture audioCapture;
|
||||
private CastSession session;
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
private Thread ioThread;
|
||||
private String status = "";
|
||||
|
||||
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
|
||||
@Override
|
||||
public void registerCallback(ICastStatusCallback callback) {
|
||||
if (callback != null) {
|
||||
callbacks.register(callback);
|
||||
broadcastStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregisterCallback(ICastStatusCallback callback) {
|
||||
if (callback != null) {
|
||||
callbacks.unregister(callback);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCasting() {
|
||||
return casting.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopCasting() {
|
||||
stopSelfSafely();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
CastNotifications.createChannels(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
stopSelfSafely();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (casting.get()) {
|
||||
return START_STICKY;
|
||||
}
|
||||
startSenderForeground(getString(R.string.connecting));
|
||||
ioThread = new Thread(() -> runCast(intent), "ScreenCastIO");
|
||||
ioThread.start();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void runCast(Intent intent) {
|
||||
if (intent == null) {
|
||||
failAndStop(getString(R.string.error_no_intent));
|
||||
return;
|
||||
}
|
||||
int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0);
|
||||
Intent data = IntentExtras.getParcelableIntent(intent, EXTRA_RESULT_DATA);
|
||||
String host = intent.getStringExtra(EXTRA_HOST);
|
||||
int port = intent.getIntExtra(EXTRA_PORT, com.foxx.androidcast.CastConfig.CAST_PORT);
|
||||
String pin = intent.getStringExtra(EXTRA_PIN);
|
||||
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
|
||||
CastSettings settings = IntentExtras.getCastSettings(intent);
|
||||
|
||||
if (data == null) {
|
||||
failAndStop(getString(R.string.error_projection_data));
|
||||
return;
|
||||
}
|
||||
if (host == null || pin == null || settings == null) {
|
||||
failAndStop(getString(R.string.error_missing_params));
|
||||
return;
|
||||
}
|
||||
|
||||
acquireWakeLock();
|
||||
try {
|
||||
updateStatus(getString(R.string.connecting_to, host));
|
||||
session = new CastSession(CastTransportFactory.create(settings));
|
||||
connectWithRetry(host, port);
|
||||
updateStatus(getString(R.string.authenticating));
|
||||
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
|
||||
casting.set(true);
|
||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
||||
while (!stopping.get()) {
|
||||
Thread.sleep(500);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Cast failed", e);
|
||||
failAndStop(e.getMessage() != null ? e.getMessage() : e.toString());
|
||||
} finally {
|
||||
releaseAll();
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
}
|
||||
}
|
||||
|
||||
private void connectWithRetry(String host, int port) throws IOException {
|
||||
IOException last = null;
|
||||
for (int i = 0; i < CONNECT_RETRIES; i++) {
|
||||
try {
|
||||
session.connect(host, port);
|
||||
return;
|
||||
} catch (IOException e) {
|
||||
last = e;
|
||||
Log.w(TAG, "Connect attempt " + (i + 1) + " failed", e);
|
||||
try {
|
||||
Thread.sleep(CONNECT_RETRY_MS);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted", ie);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IOException("Could not connect to " + host + ":" + port, last);
|
||||
}
|
||||
|
||||
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
|
||||
if (Looper.myLooper() != mainHandler.getLooper()) {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<IOException> error = new AtomicReference<>();
|
||||
mainHandler.post(() -> {
|
||||
try {
|
||||
startCaptureOnMainThread(resultCode, data, settings);
|
||||
} catch (IOException e) {
|
||||
error.set(e);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (!latch.await(15, TimeUnit.SECONDS)) {
|
||||
throw new IOException("Capture setup timeout");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted", e);
|
||||
}
|
||||
if (error.get() != null) {
|
||||
throw error.get();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
MediaProjectionManager mgr =
|
||||
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||
projection = mgr.getMediaProjection(resultCode, data);
|
||||
if (projection == null) {
|
||||
throw new IOException("MediaProjection unavailable");
|
||||
}
|
||||
|
||||
projection.registerCallback(new MediaProjection.Callback() {
|
||||
@Override
|
||||
public void onStop() {
|
||||
Log.w(TAG, "MediaProjection stopped");
|
||||
stopping.set(true);
|
||||
}
|
||||
}, mainHandler);
|
||||
|
||||
DisplayMetrics metrics = new DisplayMetrics();
|
||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||
CastResolution.Size size = CastResolution.compute(metrics, settings);
|
||||
|
||||
videoEncoder = new VideoEncoder();
|
||||
VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
|
||||
private boolean configSent;
|
||||
|
||||
@Override
|
||||
public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
|
||||
if (configSent || stopping.get()) {
|
||||
return;
|
||||
}
|
||||
configSent = true;
|
||||
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
|
||||
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||
sendSafe(CastProtocol.MSG_VIDEO_FRAME,
|
||||
() -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData));
|
||||
}
|
||||
};
|
||||
|
||||
Surface surface = videoEncoder.prepare(size.width, size.height, settings, videoCb);
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
"AndroidCast",
|
||||
size.width,
|
||||
size.height,
|
||||
size.densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
surface,
|
||||
null,
|
||||
null);
|
||||
|
||||
if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
audioEncoder = new AudioEncoder();
|
||||
audioEncoder.prepare(new AudioEncoder.Callback() {
|
||||
private boolean configSent;
|
||||
|
||||
@Override
|
||||
public void onConfig(int sampleRate, int channels, byte[] csd0) {
|
||||
if (configSent || stopping.get()) {
|
||||
return;
|
||||
}
|
||||
configSent = true;
|
||||
sendSafe(CastProtocol.MSG_AUDIO_CONFIG,
|
||||
() -> CastProtocol.audioConfigPayload(sampleRate, channels, csd0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEncodedFrame(long ptsUs, byte[] frameData) {
|
||||
sendSafe(CastProtocol.MSG_AUDIO_FRAME,
|
||||
() -> CastProtocol.audioFramePayload(ptsUs, frameData));
|
||||
}
|
||||
});
|
||||
audioCapture = new AudioCapture();
|
||||
audioCapture.start(projection, (pcm, length, ptsUs) -> {
|
||||
if (audioEncoder != null) {
|
||||
audioEncoder.queuePcm(pcm, length, ptsUs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
videoEncoder.requestKeyframe();
|
||||
String mode = session.getTransport().getModeLabel();
|
||||
updateStatus(getString(R.string.casting_active, mode, size.width, size.height));
|
||||
}
|
||||
|
||||
private void send(byte type, byte[] payloadBytes) throws IOException {
|
||||
if (stopping.get() || session == null) {
|
||||
return;
|
||||
}
|
||||
session.send(type, payloadBytes);
|
||||
}
|
||||
|
||||
private void sendSafe(byte type, PayloadBuilder builder) {
|
||||
try {
|
||||
send(type, builder.build());
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Send failed", e);
|
||||
stopping.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
private interface PayloadBuilder {
|
||||
byte[] build() throws IOException;
|
||||
}
|
||||
|
||||
private void acquireWakeLock() {
|
||||
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AndroidCast:Send");
|
||||
wakeLock.acquire(4 * 60 * 60 * 1000L);
|
||||
}
|
||||
|
||||
private void releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
}
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
private void releaseAll() {
|
||||
stopping.set(true);
|
||||
casting.set(false);
|
||||
if (virtualDisplay != null) {
|
||||
virtualDisplay.release();
|
||||
virtualDisplay = null;
|
||||
}
|
||||
if (videoEncoder != null) {
|
||||
videoEncoder.stop();
|
||||
videoEncoder = null;
|
||||
}
|
||||
if (audioCapture != null) {
|
||||
audioCapture.stop();
|
||||
audioCapture = null;
|
||||
}
|
||||
if (audioEncoder != null) {
|
||||
audioEncoder.stop();
|
||||
audioEncoder = null;
|
||||
}
|
||||
if (projection != null) {
|
||||
projection.stop();
|
||||
projection = null;
|
||||
}
|
||||
if (session != null) {
|
||||
try {
|
||||
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
session.close();
|
||||
session = null;
|
||||
}
|
||||
releaseWakeLock();
|
||||
}
|
||||
|
||||
private void failAndStop(String message) {
|
||||
updateStatus(message);
|
||||
stopping.set(true);
|
||||
}
|
||||
|
||||
private void stopSelfSafely() {
|
||||
stopping.set(true);
|
||||
releaseAll();
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
private void startSenderForeground(String text) {
|
||||
android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(CastNotifications.ID_SENDER, n,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
|
||||
} else {
|
||||
startForeground(CastNotifications.ID_SENDER, n);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(String text) {
|
||||
status = text;
|
||||
broadcastStatus(text);
|
||||
android.app.NotificationManager nm = getSystemService(android.app.NotificationManager.class);
|
||||
nm.notify(CastNotifications.ID_SENDER,
|
||||
CastNotifications.sender(this, text, ScreenCastService.class));
|
||||
}
|
||||
|
||||
private void broadcastStatus(String s) {
|
||||
int n = callbacks.beginBroadcast();
|
||||
for (int i = 0; i < n; i++) {
|
||||
try {
|
||||
callbacks.getBroadcastItem(i).onStatus(s);
|
||||
} catch (RemoteException ignored) {
|
||||
}
|
||||
}
|
||||
callbacks.finishBroadcast();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
stopping.set(true);
|
||||
releaseAll();
|
||||
callbacks.kill();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.foxx.androidcast.sender;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.media.projection.MediaProjectionManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.PermissionHelper;
|
||||
import com.foxx.androidcast.PlatformCastSupport;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.SettingsUi;
|
||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SenderActivity extends AppCompatActivity {
|
||||
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
||||
|
||||
private final CastSettings castSettings = new CastSettings();
|
||||
private DiscoveryManager discovery;
|
||||
private List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
||||
private ArrayAdapter<String> listAdapter;
|
||||
private DiscoveryManager.DiscoveredDevice selected;
|
||||
|
||||
private EditText pinInput;
|
||||
private TextView statusText;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_sender);
|
||||
PermissionHelper.request(this);
|
||||
SettingsUi.bind(this, castSettings);
|
||||
|
||||
pinInput = findViewById(R.id.edit_pin);
|
||||
pinInput.setText(CastConfig.DEFAULT_PIN);
|
||||
statusText = findViewById(R.id.text_status);
|
||||
ListView listView = findViewById(R.id.list_devices);
|
||||
listAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, new ArrayList<>());
|
||||
listView.setAdapter(listAdapter);
|
||||
listView.setOnItemClickListener((parent, view, position, id) -> {
|
||||
if (position < devices.size()) {
|
||||
selected = devices.get(position);
|
||||
if (!castSettings.getTransport().equals(selected.transport)) {
|
||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
statusText.setText("Selected: " + selected.name + " (" + selected.host + ", "
|
||||
+ selected.transport.toUpperCase() + ")");
|
||||
}
|
||||
});
|
||||
|
||||
discovery = new DiscoveryManager(this);
|
||||
discovery.setListener(deviceList -> {
|
||||
devices = deviceList;
|
||||
List<String> labels = new ArrayList<>();
|
||||
for (DiscoveryManager.DiscoveredDevice d : deviceList) {
|
||||
labels.add(d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]");
|
||||
}
|
||||
listAdapter.clear();
|
||||
listAdapter.addAll(labels);
|
||||
listAdapter.notifyDataSetChanged();
|
||||
statusText.setText(deviceList.isEmpty()
|
||||
? getString(R.string.searching)
|
||||
: getString(R.string.found_receivers, deviceList.size()));
|
||||
});
|
||||
|
||||
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
|
||||
findViewById(R.id.btn_start_cast).setOnClickListener(v ->
|
||||
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
restartDiscovery();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
if (discovery != null) {
|
||||
discovery.stop();
|
||||
}
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
private void restartDiscovery() {
|
||||
if (discovery != null) {
|
||||
discovery.stop();
|
||||
}
|
||||
discovery.startBrowsing();
|
||||
}
|
||||
|
||||
private void startCastFlow() {
|
||||
if (selected == null) {
|
||||
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
if (!castSettings.getTransport().equals(selected.transport)) {
|
||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
String pin = pinInput.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(pin)) {
|
||||
Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
pendingPin = pin;
|
||||
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||
startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION);
|
||||
}
|
||||
|
||||
private String pendingPin;
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
||||
return;
|
||||
}
|
||||
if (resultCode != Activity.RESULT_OK || data == null || selected == null) {
|
||||
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
|
||||
if (deviceName == null) {
|
||||
deviceName = Build.MODEL;
|
||||
}
|
||||
|
||||
Intent serviceIntent = new Intent(this, ScreenCastService.class);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data));
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, selected.host);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, selected.port);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
|
||||
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, deviceName);
|
||||
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
Toast.makeText(this, R.string.sender_check_notification, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
}
|
||||
144
app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java
Normal file
144
app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java
Normal file
@@ -0,0 +1,144 @@
|
||||
package com.foxx.androidcast.sender;
|
||||
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** H.264 encoder using MediaCodec (AVC). */
|
||||
public class VideoEncoder {
|
||||
private static final String TAG = "VideoEncoder";
|
||||
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||
|
||||
public interface Callback {
|
||||
void onConfig(int width, int height, byte[] csd0, byte[] csd1);
|
||||
|
||||
void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] data);
|
||||
}
|
||||
|
||||
private MediaCodec codec;
|
||||
private Surface inputSurface;
|
||||
private Callback callback;
|
||||
private int width;
|
||||
private int height;
|
||||
private boolean running;
|
||||
private Thread drainThread;
|
||||
|
||||
public Surface prepare(int width, int height, CastSettings settings, Callback callback) throws IOException {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.callback = callback;
|
||||
|
||||
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
|
||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate());
|
||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps());
|
||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL);
|
||||
|
||||
codec = MediaCodec.createEncoderByType(MIME);
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
||||
inputSurface = codec.createInputSurface();
|
||||
codec.start();
|
||||
running = true;
|
||||
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
||||
drainThread.start();
|
||||
return inputSurface;
|
||||
}
|
||||
|
||||
public void requestKeyframe() {
|
||||
if (codec == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Bundle params = new Bundle();
|
||||
params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
|
||||
codec.setParameters(params);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Keyframe request failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
if (drainThread != null) {
|
||||
try {
|
||||
drainThread.join(1500);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
if (codec != null) {
|
||||
try {
|
||||
codec.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
codec.release();
|
||||
codec = null;
|
||||
}
|
||||
if (inputSurface != null) {
|
||||
inputSurface.release();
|
||||
inputSurface = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void drainLoop() {
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean configSent = false;
|
||||
while (running) {
|
||||
int index = codec.dequeueOutputBuffer(info, 10_000);
|
||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||
continue;
|
||||
}
|
||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
sendConfig(codec.getOutputFormat(), configSent);
|
||||
configSent = true;
|
||||
continue;
|
||||
}
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
ByteBuffer buffer = codec.getOutputBuffer(index);
|
||||
if (buffer != null && info.size > 0) {
|
||||
byte[] data = new byte[info.size];
|
||||
buffer.position(info.offset);
|
||||
buffer.limit(info.offset + info.size);
|
||||
buffer.get(data);
|
||||
boolean keyFrame = (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0
|
||||
&& (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0;
|
||||
if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0 && callback != null) {
|
||||
callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data);
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(index, false);
|
||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendConfig(MediaFormat format, boolean alreadySent) {
|
||||
if (alreadySent || callback == null) {
|
||||
return;
|
||||
}
|
||||
ByteBuffer sps = format.getByteBuffer("csd-0");
|
||||
ByteBuffer pps = format.getByteBuffer("csd-1");
|
||||
byte[] csd0 = sps != null ? toArray(sps) : new byte[0];
|
||||
byte[] csd1 = pps != null ? toArray(pps) : new byte[0];
|
||||
callback.onConfig(width, height, csd0, csd1);
|
||||
}
|
||||
|
||||
private static byte[] toArray(ByteBuffer buffer) {
|
||||
byte[] arr = new byte[buffer.remaining()];
|
||||
ByteBuffer dup = buffer.duplicate();
|
||||
dup.get(arr);
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#1565C0"
|
||||
android:pathData="M20,30 L88,54 L20,78 Z" />
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M30,40 L78,54 L30,68 Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M21,3H3c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h5v2h8v-2h5c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
||||
36
app/src/main/res/layout/activity_main.xml
Normal file
36
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_send"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/send_screen" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_receive"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/receive_screen" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/main_hint"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
19
app/src/main/res/layout/activity_playback.xml
Normal file
19
app/src/main/res/layout/activity_playback.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000">
|
||||
|
||||
<com.foxx.androidcast.ui.AspectRatioFrameLayout
|
||||
android:id="@+id/aspect_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center">
|
||||
|
||||
<SurfaceView
|
||||
android:id="@+id/surface_playback"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center" />
|
||||
</com.foxx.androidcast.ui.AspectRatioFrameLayout>
|
||||
</FrameLayout>
|
||||
42
app/src/main/res/layout/activity_receiver.xml
Normal file
42
app/src/main/res/layout/activity_receiver.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/receiver_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<include layout="@layout/activity_settings_panel" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_pin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/pin_hint"
|
||||
android:inputType="numberPassword" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_start_receiver"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/start_receiver" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/waiting_sender" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
64
app/src/main/res/layout/activity_sender.xml
Normal file
64
app/src/main/res/layout/activity_sender.xml
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sender_title"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/searching" />
|
||||
|
||||
<include layout="@layout/activity_settings_panel" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_pin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="@string/pin_hint"
|
||||
android:inputType="numberPassword"
|
||||
android:maxLength="16" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/list_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_refresh"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/refresh" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_start_cast"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/start_cast" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
54
app/src/main/res/layout/activity_settings_panel.xml
Normal file
54
app/src/main/res/layout/activity_settings_panel.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_title"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/label_transport" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_transport"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/label_quality" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_quality"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/label_resolution" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_resolution"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/checkbox_audio"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:checked="true"
|
||||
android:text="@string/label_audio" />
|
||||
</LinearLayout>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values/colors.xml
Normal file
4
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0D47A1</color>
|
||||
</resources>
|
||||
56
app/src/main/res/values/strings.xml
Normal file
56
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Android Cast</string>
|
||||
<string name="send_screen">Send screen (tablet / phone)</string>
|
||||
<string name="receive_screen">Receive on TV / projector</string>
|
||||
<string name="main_hint">Same Wi‑Fi required. Default PIN: 1234. Match transport (TCP/UDP) on both devices.</string>
|
||||
<string name="sender_title">Cast to receiver</string>
|
||||
<string name="receiver_title">Waiting for cast</string>
|
||||
<string name="pin_hint">PIN</string>
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="start_cast">Start cast</string>
|
||||
<string name="searching">Searching for receivers…</string>
|
||||
<string name="waiting_sender">Waiting for sender…</string>
|
||||
<string name="settings_title">Stream options</string>
|
||||
<string name="label_transport">Transport</string>
|
||||
<string name="label_quality">Quality (bitrate / FPS)</string>
|
||||
<string name="label_resolution">Capture resolution</string>
|
||||
<string name="label_audio">Include audio (playback capture, Android 10+)</string>
|
||||
<string name="label_audio_unavailable">Audio unavailable (requires Android 10+)</string>
|
||||
<string name="audio_unavailable_title">Video only</string>
|
||||
<string name="audio_unavailable_message">This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only.</string>
|
||||
<string name="continue_video_only">Continue (video only)</string>
|
||||
<string name="transport_tcp">TCP (reliable)</string>
|
||||
<string name="transport_udp">UDP (best effort)</string>
|
||||
<string name="quality_low">Low (~0.8 Mbps)</string>
|
||||
<string name="quality_medium">Medium (~2 Mbps)</string>
|
||||
<string name="quality_high">High (~4 Mbps)</string>
|
||||
<string name="resolution_full">Full display</string>
|
||||
<string name="resolution_75">75% scale</string>
|
||||
<string name="resolution_half">50% scale</string>
|
||||
<string name="resolution_720p">Cap height 720p</string>
|
||||
<string name="transport_mismatch">Transport must match the receiver (TCP/UDP)</string>
|
||||
<string name="select_receiver">Select a receiver first</string>
|
||||
<string name="enter_pin">Enter PIN</string>
|
||||
<string name="capture_denied">Screen capture permission denied</string>
|
||||
<string name="casting_started">Casting started</string>
|
||||
<string name="found_receivers">Found %1$d receiver(s)</string>
|
||||
<string name="receiver_ready">Ready — PIN %1$s · %2$s</string>
|
||||
<string name="casting_from">Casting from %1$s</string>
|
||||
<string name="playing_resolution">Playing %1$dx%2$d</string>
|
||||
<string name="stop">Stop</string>
|
||||
<string name="start_receiver">Start listening (keeps running in background)</string>
|
||||
<string name="receiver_starting">Starting receiver service…</string>
|
||||
<string name="receiver_listening">Listening for cast</string>
|
||||
<string name="waiting_surface">Connected — open video surface…</string>
|
||||
<string name="surface_attached">Video surface ready</string>
|
||||
<string name="connecting">Connecting…</string>
|
||||
<string name="connecting_to">Connecting to %1$s…</string>
|
||||
<string name="authenticating">Authenticating…</string>
|
||||
<string name="casting_active">Casting via %1$s · %2$dx%3$d</string>
|
||||
<string name="error_projection_data">Screen capture data missing (retry cast)</string>
|
||||
<string name="error_missing_params">Missing host, PIN, or settings</string>
|
||||
<string name="error_no_intent">No cast intent</string>
|
||||
<string name="sender_check_notification">Cast starting — see notification for status</string>
|
||||
<string name="playback_opened">Fullscreen playback %1$dx%2$d</string>
|
||||
</resources>
|
||||
15
app/src/main/res/values/themes.xml
Normal file
15
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.AndroidCast" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<item name="colorPrimary">#1565C0</item>
|
||||
<item name="colorPrimaryVariant">#0D47A1</item>
|
||||
<item name="colorOnPrimary">#FFFFFF</item>
|
||||
<item name="colorSecondary">#26A69A</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.AndroidCast.Fullscreen" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
3
build.gradle
Normal file
3
build.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '8.7.3' apply false
|
||||
}
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
# Uncomment and set if default `java` is too new (e.g. JDK 25):
|
||||
# org.gradle.java.home=/usr/lib/jvm/openjdk-bin-21
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
17
gradlew
vendored
Executable file
17
gradlew
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
|
||||
APP_HOME=$( cd "${0%/*}" && pwd -P ) || exit
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
else
|
||||
JAVACMD=java
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
17
settings.gradle
Normal file
17
settings.gradle
Normal file
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "AndroidCast"
|
||||
include ':app'
|
||||
Reference in New Issue
Block a user