diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7a390f3..9884e5a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -160,6 +160,11 @@
android:exported="false"
android:foregroundServiceType="mediaProjection" />
+
+
defaults() {
+ List ice = new ArrayList<>();
+ ice.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
+ ice.add(PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer());
+ return ice;
+ }
+
+ public static List fromHealth(JSONObject health) {
+ if (health == null) {
+ return defaults();
+ }
+ JSONObject sfu = health.optJSONObject("sfu");
+ if (sfu == null) {
+ return defaults();
+ }
+ JSONArray servers = sfu.optJSONArray("ice_servers");
+ if (servers == null || servers.length() == 0) {
+ return defaults();
+ }
+ List ice = new ArrayList<>();
+ for (int i = 0; i < servers.length(); i++) {
+ JSONObject entry = servers.optJSONObject(i);
+ if (entry == null) {
+ continue;
+ }
+ PeerConnection.IceServer server = parseEntry(entry);
+ if (server != null) {
+ ice.add(server);
+ }
+ }
+ return ice.isEmpty() ? defaults() : ice;
+ }
+
+ private static PeerConnection.IceServer parseEntry(JSONObject entry) {
+ Object urlsRaw = entry.opt("urls");
+ List urls = new ArrayList<>();
+ if (urlsRaw instanceof JSONArray arr) {
+ for (int i = 0; i < arr.length(); i++) {
+ String u = arr.optString(i, "").trim();
+ if (!u.isEmpty()) {
+ urls.add(u);
+ }
+ }
+ } else {
+ String u = entry.optString("urls", "").trim();
+ if (!u.isEmpty()) {
+ urls.add(u);
+ }
+ }
+ if (urls.isEmpty()) {
+ return null;
+ }
+ PeerConnection.IceServer.Builder builder = PeerConnection.IceServer.builder(urls);
+ String user = entry.optString("username", entry.optString("user", ""));
+ String pass = entry.optString("credential", entry.optString("password", ""));
+ if (!user.isEmpty()) {
+ builder.setUsername(user);
+ }
+ if (!pass.isEmpty()) {
+ builder.setPassword(pass);
+ }
+ return builder.createIceServer();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java
index 801ee04..ee31906 100644
--- a/app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java
@@ -2,6 +2,7 @@ package com.foxx.androidcast.sfu;
import android.content.Context;
import android.content.Intent;
+import android.media.projection.MediaProjectionManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@@ -20,10 +21,12 @@ import com.foxx.androidcast.R;
import org.webrtc.SurfaceViewRenderer;
-/** Dev-only SFU lab: libwebrtc publish/subscribe to Janus VideoRoom (M1). */
+/** Dev-only SFU lab: libwebrtc publish/subscribe to Janus VideoRoom (M1 camera, M2 screen). */
public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.Callback {
private static final int REQ_LOGIN = 9108;
+ private int pendingScreenRoomId;
+
private SurfaceViewRenderer localVideo;
private SurfaceViewRenderer remoteVideo;
private TextView logView;
@@ -37,6 +40,16 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
refreshAuthStatus();
});
+ private final ActivityResultLauncher screenCaptureLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+ if (result.getResultCode() != RESULT_OK || result.getData() == null) {
+ Toast.makeText(this, R.string.sfu_lab_screen_denied, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ SfuLabForegroundService.start(this);
+ session.publishScreenShare(pendingScreenRoomId, result.getData());
+ });
+
public static void openIfAvailable(Context context) {
if (!SfuRelayGate.isAvailable(context)) {
Toast.makeText(context, R.string.sfu_lab_unavailable, Toast.LENGTH_LONG).show();
@@ -83,6 +96,7 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
findViewById(R.id.btn_sfu_create_room).setOnClickListener(v -> createRoom());
findViewById(R.id.btn_sfu_publish).setOnClickListener(v -> publish());
+ findViewById(R.id.btn_sfu_publish_screen).setOnClickListener(v -> publishScreen());
findViewById(R.id.btn_sfu_subscribe).setOnClickListener(v -> subscribe());
findViewById(R.id.btn_sfu_stop).setOnClickListener(v -> stopSession());
@@ -93,6 +107,7 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
@Override
protected void onDestroy() {
stopSession();
+ SfuLabForegroundService.stop(this);
localVideo.release();
remoteVideo.release();
super.onDestroy();
@@ -146,6 +161,21 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
session.publishCamera(roomId);
}
+ private void publishScreen() {
+ int roomId = parseInt(((EditText) findViewById(R.id.edit_sfu_room_id)).getText().toString(), 0);
+ if (roomId <= 0) {
+ Toast.makeText(this, R.string.sfu_lab_room_required, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (!SfuAuthStore.hasSession(this)) {
+ Toast.makeText(this, R.string.sfu_lab_need_login, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ pendingScreenRoomId = roomId;
+ MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
+ screenCaptureLauncher.launch(mgr.createScreenCaptureIntent());
+ }
+
private void subscribe() {
int roomId = parseInt(((EditText) findViewById(R.id.edit_sfu_room_id)).getText().toString(), 0);
int feedId = parseInt(((EditText) findViewById(R.id.edit_sfu_feed_id)).getText().toString(), 0);
@@ -164,6 +194,7 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
if (session != null) {
session.stop();
}
+ SfuLabForegroundService.stop(this);
}
private static int parseInt(String raw, int fallback) {
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuLabForegroundService.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuLabForegroundService.java
new file mode 100644
index 0000000..33109f5
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuLabForegroundService.java
@@ -0,0 +1,66 @@
+package com.foxx.androidcast.sfu;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.IBinder;
+
+import androidx.annotation.Nullable;
+
+import com.foxx.androidcast.CastNotifications;
+import com.foxx.androidcast.R;
+
+/**
+ * Foreground service holder for SFU lab MediaProjection screen capture (Android 10+).
+ */
+public final class SfuLabForegroundService extends Service {
+ private static volatile boolean running;
+
+ public static boolean isRunning() {
+ return running;
+ }
+
+ public static void start(Context context) {
+ Intent intent = new Intent(context, SfuLabForegroundService.class);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ }
+
+ public static void stop(Context context) {
+ context.stopService(new Intent(context, SfuLabForegroundService.class));
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ CastNotifications.createChannels(this);
+ running = true;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ startForeground(
+ CastNotifications.ID_SENDER + 200,
+ CastNotifications.sender(
+ this,
+ getString(R.string.sfu_lab_screen_active),
+ SfuLabForegroundService.class));
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ running = false;
+ super.onDestroy();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java
index 490d1a0..100289a 100644
--- a/app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java
@@ -1,6 +1,7 @@
package com.foxx.androidcast.sfu;
import android.content.Context;
+import android.content.Intent;
import android.util.Log;
import org.json.JSONObject;
@@ -17,14 +18,17 @@ import org.webrtc.MediaConstraints;
import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.RtpReceiver;
+import org.webrtc.ScreenCapturerAndroid;
import org.webrtc.SdpObserver;
import org.webrtc.SessionDescription;
import org.webrtc.SurfaceTextureHelper;
import org.webrtc.SurfaceViewRenderer;
+import org.webrtc.VideoCapturer;
import org.webrtc.VideoSource;
import org.webrtc.VideoTrack;
-import java.util.ArrayList;
+import android.media.projection.MediaProjection;
+
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -50,7 +54,7 @@ public final class SfuRtcSession implements PeerConnection.Observer {
private JanusWebSocketClient janusWs;
private VideoSource videoSource;
private VideoTrack localVideo;
- private CameraVideoCapturer capturer;
+ private VideoCapturer capturer;
private SurfaceTextureHelper textureHelper;
private SurfaceViewRenderer localRenderer;
private SurfaceViewRenderer remoteRenderer;
@@ -93,28 +97,12 @@ public final class SfuRtcSession implements PeerConnection.Observer {
}
public void publishCamera(int roomId) {
- executor.execute(() -> {
- try {
- ensureFactory(appContext);
- SfuSignalingClient api = new SfuSignalingClient(appContext);
- startCameraCapture();
- peerConnection = createPeerConnection();
- if (localVideo != null) {
- peerConnection.addTrack(localVideo, List.of("stream"));
- }
- AudioSource audioSource = factory.createAudioSource(new MediaConstraints());
- AudioTrack audioTrack = factory.createAudioTrack("audio0", audioSource);
- peerConnection.addTrack(audioTrack, List.of("stream"));
- SessionDescription offer = createOffer(peerConnection);
- peerConnection.setLocalDescription(simpleSdpObserver("local"), offer);
- JSONObject join = api.joinPublisher(roomId, offer.type.canonicalForm(), offer.description);
- applyJoinResponse(join);
- log("Publisher joined room " + roomId);
- } catch (Exception e) {
- Log.e(TAG, "publish", e);
- callback.onError(e.getMessage() != null ? e.getMessage() : "publish failed");
- }
- });
+ executor.execute(() -> runPublish(roomId, this::startCameraCapture));
+ }
+
+ /** M2: publish device screen via MediaProjection (matches browser getDisplayMedia lab path). */
+ public void publishScreenShare(int roomId, Intent projectionData) {
+ executor.execute(() -> runPublish(roomId, () -> startScreenCapture(projectionData)));
}
public void subscribe(int roomId, int feedId) {
@@ -122,7 +110,7 @@ public final class SfuRtcSession implements PeerConnection.Observer {
try {
ensureFactory(appContext);
SfuSignalingClient api = new SfuSignalingClient(appContext);
- peerConnection = createPeerConnection();
+ peerConnection = createPeerConnection(loadIceServers(api));
peerConnection.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO,
new org.webrtc.RtpTransceiver.RtpTransceiverInit(
org.webrtc.RtpTransceiver.RtpTransceiverDirection.RECV_ONLY));
@@ -147,19 +135,7 @@ public final class SfuRtcSession implements PeerConnection.Observer {
janusWs.close();
janusWs = null;
}
- if (capturer != null) {
- try {
- capturer.stopCapture();
- } catch (InterruptedException ignored) {
- Thread.currentThread().interrupt();
- }
- capturer.dispose();
- capturer = null;
- }
- if (textureHelper != null) {
- textureHelper.dispose();
- textureHelper = null;
- }
+ disposeCapturer();
if (videoSource != null) {
videoSource.dispose();
videoSource = null;
@@ -175,6 +151,29 @@ public final class SfuRtcSession implements PeerConnection.Observer {
});
}
+ private void runPublish(int roomId, Runnable startCapture) {
+ try {
+ ensureFactory(appContext);
+ SfuSignalingClient api = new SfuSignalingClient(appContext);
+ startCapture.run();
+ peerConnection = createPeerConnection(loadIceServers(api));
+ if (localVideo != null) {
+ peerConnection.addTrack(localVideo, List.of("stream"));
+ }
+ AudioSource audioSource = factory.createAudioSource(new MediaConstraints());
+ AudioTrack audioTrack = factory.createAudioTrack("audio0", audioSource);
+ peerConnection.addTrack(audioTrack, List.of("stream"));
+ SessionDescription offer = createOffer(peerConnection);
+ peerConnection.setLocalDescription(simpleSdpObserver("local"), offer);
+ JSONObject join = api.joinPublisher(roomId, offer.type.canonicalForm(), offer.description);
+ applyJoinResponse(join);
+ log("Publisher joined room " + roomId);
+ } catch (Exception e) {
+ Log.e(TAG, "publish", e);
+ callback.onError(e.getMessage() != null ? e.getMessage() : "publish failed");
+ }
+ }
+
private void applyJoinResponse(JSONObject join) throws org.json.JSONException {
long sessionId = join.getLong("session_id");
long handleId = join.getLong("handle_id");
@@ -207,10 +206,16 @@ public final class SfuRtcSession implements PeerConnection.Observer {
});
}
- private PeerConnection createPeerConnection() {
- List ice = new ArrayList<>();
- ice.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
- ice.add(PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer());
+ private List loadIceServers(SfuSignalingClient api) {
+ try {
+ return SfuIceServers.fromHealth(api.health());
+ } catch (Exception e) {
+ Log.w(TAG, "ICE from health failed, using defaults", e);
+ return SfuIceServers.defaults();
+ }
+ }
+
+ private PeerConnection createPeerConnection(List ice) {
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(ice);
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
PeerConnection pc = factory.createPeerConnection(config, this);
@@ -236,16 +241,50 @@ public final class SfuRtcSession implements PeerConnection.Observer {
throw new IllegalStateException("No camera");
}
capturer = enumerator.createCapturer(device, null);
+ startVideoCapture(false, 1280, 720, 30);
+ }
+
+ private void startScreenCapture(Intent projectionData) {
+ if (projectionData == null) {
+ throw new IllegalStateException("Screen capture consent missing");
+ }
+ capturer = new ScreenCapturerAndroid(projectionData, new MediaProjection.Callback() {
+ @Override
+ public void onStop() {
+ log("MediaProjection stopped");
+ stop();
+ }
+ });
+ startVideoCapture(true, 1280, 720, 15);
+ }
+
+ private void startVideoCapture(boolean screencast, int width, int height, int fps) {
textureHelper = SurfaceTextureHelper.create("SfuCap", eglBase.getEglBaseContext());
- videoSource = factory.createVideoSource(capturer.isScreencast());
+ videoSource = factory.createVideoSource(screencast);
capturer.initialize(textureHelper, appContext, videoSource.getCapturerObserver());
- capturer.startCapture(1280, 720, 30);
+ capturer.startCapture(width, height, fps);
localVideo = factory.createVideoTrack("video0", videoSource);
if (localRenderer != null) {
localVideo.addSink(localRenderer);
}
}
+ private void disposeCapturer() {
+ if (capturer != null) {
+ try {
+ capturer.stopCapture();
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ capturer.dispose();
+ capturer = null;
+ }
+ if (textureHelper != null) {
+ textureHelper.dispose();
+ textureHelper = null;
+ }
+ }
+
private SessionDescription createOffer(PeerConnection pc) {
final SessionDescription[] holder = new SessionDescription[1];
final Exception[] error = new Exception[1];
diff --git a/app/src/main/res/layout/activity_sfu_lab.xml b/app/src/main/res/layout/activity_sfu_lab.xml
index 5c68b10..2952010 100644
--- a/app/src/main/res/layout/activity_sfu_lab.xml
+++ b/app/src/main/res/layout/activity_sfu_lab.xml
@@ -61,11 +61,17 @@
android:id="@+id/btn_sfu_publish"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/sfu_lab_publish" />
+
+
External display capture policy (future)
Show USB-tether transport in cast settings (stub, roadmap E)
- Enable Janus SFU relay lab (libwebrtc M1)
+ Enable Janus SFU relay lab (libwebrtc M1/M2)
Open SFU lab
SFU lab
SFU lab disabled — enable in developer settings
@@ -276,6 +276,9 @@
Publisher feed id
Create room
Publish camera
+ Publish screen share
+ SFU screen share active
+ Screen capture permission denied
Subscribe
Stop
Local preview
diff --git a/app/src/test/java/com/foxx/androidcast/sfu/SfuIceServersTest.java b/app/src/test/java/com/foxx/androidcast/sfu/SfuIceServersTest.java
new file mode 100644
index 0000000..26acc35
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/sfu/SfuIceServersTest.java
@@ -0,0 +1,43 @@
+package com.foxx.androidcast.sfu;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.json.JSONObject;
+import org.junit.Test;
+import org.webrtc.PeerConnection;
+
+import java.util.List;
+
+public class SfuIceServersTest {
+ @Test
+ public void defaults_includeGoogleStun() {
+ List ice = SfuIceServers.defaults();
+ assertEquals(2, ice.size());
+ assertTrue(ice.get(0).urls.get(0).contains("stun:"));
+ }
+
+ @Test
+ public void fromHealth_parsesTurnEntry() throws Exception {
+ JSONObject health = new JSONObject("""
+ {"sfu":{"ice_servers":[
+ {"urls":"stun:stun.l.google.com:19302"},
+ {"urls":"turn:10.7.16.237:3478?transport=udp","username":"acturn","credential":"secret"}
+ ]}}
+ """);
+ List ice = SfuIceServers.fromHealth(health);
+ assertEquals(2, ice.size());
+ assertTrue(ice.get(1).urls.get(0).startsWith("turn:"));
+ assertEquals("acturn", ice.get(1).username);
+ assertEquals("secret", ice.get(1).password);
+ }
+
+ @Test
+ public void fromHealth_emptyFallsBackToDefaults() throws Exception {
+ JSONObject health = new JSONObject("{\"sfu\":{}}");
+ List ice = SfuIceServers.fromHealth(health);
+ assertFalse(ice.isEmpty());
+ assertTrue(ice.get(0).urls.get(0).contains("stun:"));
+ }
+}