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" /> +