diff --git a/app/build.gradle b/app/build.gradle
index 9317b36..7516351 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -151,6 +151,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'
+ testImplementation 'org.robolectric:robolectric:4.11.1'
implementation 'com.github.mwiede:jsch:0.2.21'
@@ -159,5 +160,9 @@ dependencies {
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
implementation 'org.slf4j:slf4j-android:1.7.36'
+ // SFU lab (M1): libwebrtc publish/subscribe to Janus VideoRoom (dev-gated).
+ implementation 'io.getstream:stream-webrtc-android:1.1.3'
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0'
+
implementation project(':tunnel')
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c008456..7a390f3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -67,6 +67,18 @@
android:label="@string/developer_settings_title"
android:parentActivityName=".MainActivity" />
+
+
+
+
{
diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
index 1db8f29..d8a8522 100644
--- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
@@ -130,6 +130,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
bindDiagPingSection();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
+ bindSfuLabSection();
bindAdbWifiSection();
bindCommercialSection();
}
@@ -170,10 +171,24 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
bindRemoteAccessSection();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
+ bindSfuLabSection();
bindAdbWifiSection();
bindCommercialSection();
}
+ private void bindSfuLabSection() {
+ CheckBox sfu = findViewById(R.id.check_dev_sfu_relay);
+ Button openLab = findViewById(R.id.btn_open_sfu_lab);
+ if (sfu == null) {
+ return;
+ }
+ bindDeveloperCheckbox(sfu, AppPreferences.isDevSfuRelayEnabled(this),
+ AppPreferences::setDevSfuRelayEnabled);
+ if (openLab != null) {
+ openLab.setOnClickListener(v -> com.foxx.androidcast.sfu.SfuLabActivity.openIfAvailable(this));
+ }
+ }
+
private void bindAdbWifiSection() {
if (!BuildConfig.DEBUG) {
android.view.View section = findViewById(R.id.check_dev_adb_wifi_keeper);
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/JanusWebSocketClient.java b/app/src/main/java/com/foxx/androidcast/sfu/JanusWebSocketClient.java
new file mode 100644
index 0000000..42e58dc
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/JanusWebSocketClient.java
@@ -0,0 +1,109 @@
+package com.foxx.androidcast.sfu;
+
+import android.util.Log;
+
+import org.json.JSONObject;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+
+/** Janus Gateway WebSocket for trickle ICE + keepalive on an existing REST session. */
+public final class JanusWebSocketClient {
+ private static final String TAG = "JanusWS";
+
+ public interface Listener {
+ void onTrickle(JSONObject candidate);
+
+ void onFailure(String message);
+ }
+
+ private final OkHttpClient http = new OkHttpClient();
+ private final AtomicInteger tx = new AtomicInteger();
+ private WebSocket webSocket;
+ private Listener listener;
+ private long sessionId;
+ private long handleId;
+
+ public void connect(long sessionId, long handleId, Listener listener) {
+ this.sessionId = sessionId;
+ this.handleId = handleId;
+ this.listener = listener;
+ Request req = new Request.Builder().url(SfuEndpoints.webSocketUrl()).build();
+ webSocket = http.newWebSocket(req, new WebSocketListener() {
+ @Override
+ public void onOpen(WebSocket webSocket, Response response) {
+ Log.d(TAG, "open");
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, String text) {
+ handleMessage(text);
+ }
+
+ @Override
+ public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+ if (listener != null) {
+ listener.onFailure(t.getMessage() != null ? t.getMessage() : "websocket failed");
+ }
+ }
+ });
+ }
+
+ public void sendTrickle(JSONObject candidate) {
+ if (webSocket == null) {
+ return;
+ }
+ try {
+ JSONObject msg = new JSONObject();
+ msg.put("janus", "trickle");
+ msg.put("transaction", "tx-" + tx.incrementAndGet());
+ msg.put("session_id", sessionId);
+ msg.put("handle_id", handleId);
+ msg.put("candidate", candidate);
+ webSocket.send(msg.toString());
+ } catch (org.json.JSONException e) {
+ Log.w(TAG, "trickle encode", e);
+ }
+ }
+
+ public void sendKeepalive() {
+ if (webSocket == null) {
+ return;
+ }
+ try {
+ JSONObject msg = new JSONObject();
+ msg.put("janus", "keepalive");
+ msg.put("transaction", "tx-" + tx.incrementAndGet());
+ msg.put("session_id", sessionId);
+ webSocket.send(msg.toString());
+ } catch (org.json.JSONException e) {
+ Log.w(TAG, "keepalive encode", e);
+ }
+ }
+
+ public void close() {
+ if (webSocket != null) {
+ webSocket.close(1000, "done");
+ webSocket = null;
+ }
+ }
+
+ private void handleMessage(String text) {
+ try {
+ JSONObject msg = new JSONObject(text);
+ if ("trickle".equals(msg.optString("janus")) && listener != null) {
+ JSONObject candidate = msg.optJSONObject("candidate");
+ if (candidate != null) {
+ listener.onTrickle(candidate);
+ }
+ }
+ } catch (org.json.JSONException e) {
+ Log.w(TAG, "parse", e);
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuAuthStore.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuAuthStore.java
new file mode 100644
index 0000000..20c71c6
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuAuthStore.java
@@ -0,0 +1,31 @@
+package com.foxx.androidcast.sfu;
+
+import android.content.Context;
+import android.webkit.CookieManager;
+
+import com.foxx.androidcast.AppPreferences;
+
+/** Session cookies for SFU REST API (shared {@code ac_crash_sess} with issues console). */
+public final class SfuAuthStore {
+ private SfuAuthStore() {}
+
+ public static boolean hasSession(Context context) {
+ String cookie = cookieHeader(context);
+ return cookie != null && !cookie.isEmpty();
+ }
+
+ public static String cookieHeader(Context context) {
+ CookieManager cm = CookieManager.getInstance();
+ String fromWebView = cm.getCookie(SfuEndpoints.origin());
+ if (fromWebView != null && !fromWebView.trim().isEmpty()) {
+ return fromWebView.trim();
+ }
+ return AppPreferences.getDevSfuSessionCookie(context);
+ }
+
+ public static void clear(Context context) {
+ CookieManager cm = CookieManager.getInstance();
+ cm.setCookie(SfuEndpoints.origin(), "");
+ AppPreferences.setDevSfuSessionCookie(context, "");
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuEndpoints.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuEndpoints.java
new file mode 100644
index 0000000..5f75391
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuEndpoints.java
@@ -0,0 +1,34 @@
+package com.foxx.androidcast.sfu;
+
+import com.foxx.androidcast.network.BackendEndpoints;
+
+/** Resolved SFU URLs on the production apps host. */
+public final class SfuEndpoints {
+ private static final String ORIGIN = "https://" + BackendEndpoints.APPS_HOST;
+
+ private SfuEndpoints() {}
+
+ public static String origin() {
+ return ORIGIN;
+ }
+
+ public static String apiBase() {
+ return ORIGIN + SfuRelayGate.API_BASE;
+ }
+
+ public static String joinUrl() {
+ return apiBase() + "/join";
+ }
+
+ public static String roomsUrl() {
+ return apiBase() + "/rooms";
+ }
+
+ public static String healthUrl() {
+ return SfuRelayGate.healthUrl(ORIGIN);
+ }
+
+ public static String webSocketUrl() {
+ return "wss://" + BackendEndpoints.APPS_HOST + SfuRelayGate.WS_PATH;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java
new file mode 100644
index 0000000..801ee04
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java
@@ -0,0 +1,204 @@
+package com.foxx.androidcast.sfu;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.method.ScrollingMovementMethod;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.foxx.androidcast.CastThemeHelper;
+import com.foxx.androidcast.R;
+
+import org.webrtc.SurfaceViewRenderer;
+
+/** Dev-only SFU lab: libwebrtc publish/subscribe to Janus VideoRoom (M1). */
+public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.Callback {
+ private static final int REQ_LOGIN = 9108;
+
+ private SurfaceViewRenderer localVideo;
+ private SurfaceViewRenderer remoteVideo;
+ private TextView logView;
+ private TextView statusView;
+ private SfuRtcSession session;
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private final StringBuilder logBuf = new StringBuilder();
+
+ private final ActivityResultLauncher loginLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+ refreshAuthStatus();
+ });
+
+ public static void openIfAvailable(Context context) {
+ if (!SfuRelayGate.isAvailable(context)) {
+ Toast.makeText(context, R.string.sfu_lab_unavailable, Toast.LENGTH_LONG).show();
+ return;
+ }
+ context.startActivity(new Intent(context, SfuLabActivity.class));
+ }
+
+ @Override
+ protected void attachBaseContext(Context newBase) {
+ super.attachBaseContext(com.foxx.androidcast.CastLocaleHelper.attach(newBase));
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ CastThemeHelper.applyTheme(this);
+ super.onCreate(savedInstanceState);
+ if (!SfuRelayGate.isAvailable(this)) {
+ Toast.makeText(this, R.string.sfu_lab_unavailable, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+ setContentView(R.layout.activity_sfu_lab);
+ setTitle(R.string.sfu_lab_title);
+
+ localVideo = findViewById(R.id.sfu_local_video);
+ remoteVideo = findViewById(R.id.sfu_remote_video);
+ logView = findViewById(R.id.text_sfu_log);
+ statusView = findViewById(R.id.text_sfu_lab_status);
+ logView.setMovementMethod(new ScrollingMovementMethod());
+
+ SfuRtcSession.ensureFactory(this);
+ org.webrtc.EglBase.Context egl = SfuRtcSession.eglContext();
+ localVideo.init(egl, null);
+ remoteVideo.init(egl, null);
+ localVideo.setMirror(true);
+
+ session = new SfuRtcSession(this, this);
+ session.attachLocalRenderer(localVideo);
+ session.attachRemoteRenderer(remoteVideo);
+
+ Button login = findViewById(R.id.btn_sfu_login);
+ login.setOnClickListener(v -> loginLauncher.launch(new Intent(this, SfuLoginActivity.class)));
+
+ findViewById(R.id.btn_sfu_create_room).setOnClickListener(v -> createRoom());
+ findViewById(R.id.btn_sfu_publish).setOnClickListener(v -> publish());
+ findViewById(R.id.btn_sfu_subscribe).setOnClickListener(v -> subscribe());
+ findViewById(R.id.btn_sfu_stop).setOnClickListener(v -> stopSession());
+
+ refreshAuthStatus();
+ probeHealth();
+ }
+
+ @Override
+ protected void onDestroy() {
+ stopSession();
+ localVideo.release();
+ remoteVideo.release();
+ super.onDestroy();
+ }
+
+ private void refreshAuthStatus() {
+ boolean authed = SfuAuthStore.hasSession(this);
+ statusView.setText(getString(authed ? R.string.sfu_lab_authed : R.string.sfu_lab_need_login));
+ }
+
+ private void probeHealth() {
+ new Thread(() -> {
+ try {
+ SfuSignalingClient client = new SfuSignalingClient(this);
+ org.json.JSONObject health = client.health();
+ String status = health.optJSONObject("sfu").optString("status", "?");
+ runOnUiThread(() -> onLog("SFU health: " + status));
+ } catch (Exception e) {
+ runOnUiThread(() -> onLog("Health check failed: " + e.getMessage()));
+ }
+ }).start();
+ }
+
+ private void createRoom() {
+ new Thread(() -> {
+ try {
+ SfuSignalingClient client = new SfuSignalingClient(this);
+ org.json.JSONObject resp = client.createRoom("Android lab", "screen_cast");
+ int janusId = resp.getJSONObject("room").getInt("janus_room_id");
+ runOnUiThread(() -> {
+ EditText room = findViewById(R.id.edit_sfu_room_id);
+ room.setText(String.valueOf(janusId));
+ onLog("Created room " + janusId);
+ });
+ } catch (Exception e) {
+ runOnUiThread(() -> onError(e.getMessage()));
+ }
+ }).start();
+ }
+
+ private void publish() {
+ 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;
+ }
+ session.publishCamera(roomId);
+ }
+
+ 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);
+ if (roomId <= 0 || feedId <= 0) {
+ Toast.makeText(this, R.string.sfu_lab_feed_required, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (!SfuAuthStore.hasSession(this)) {
+ Toast.makeText(this, R.string.sfu_lab_need_login, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ session.subscribe(roomId, feedId);
+ }
+
+ private void stopSession() {
+ if (session != null) {
+ session.stop();
+ }
+ }
+
+ private static int parseInt(String raw, int fallback) {
+ try {
+ return Integer.parseInt(raw.trim());
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ @Override
+ public void onLog(String line) {
+ main.post(() -> {
+ logBuf.insert(0, line + '\n');
+ if (logBuf.length() > 4000) {
+ logBuf.setLength(4000);
+ }
+ logView.setText(logBuf.toString());
+ });
+ }
+
+ @Override
+ public void onPublisherId(int publisherId) {
+ main.post(() -> {
+ EditText feed = findViewById(R.id.edit_sfu_feed_id);
+ feed.setText(String.valueOf(publisherId));
+ onLog("Publisher feed id " + publisherId);
+ });
+ }
+
+ @Override
+ public void onError(String message) {
+ main.post(() -> {
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+ onLog("ERROR: " + message);
+ });
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuLoginActivity.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuLoginActivity.java
new file mode 100644
index 0000000..3f737de
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuLoginActivity.java
@@ -0,0 +1,46 @@
+package com.foxx.androidcast.sfu;
+
+import android.annotation.SuppressLint;
+import android.os.Bundle;
+import android.webkit.CookieManager;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.foxx.androidcast.CastThemeHelper;
+import com.foxx.androidcast.R;
+import com.foxx.androidcast.network.BackendEndpoints;
+
+/** WebView login to issues console; cookies shared with SFU REST API. */
+public class SfuLoginActivity extends AppCompatActivity {
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ CastThemeHelper.applyTheme(this);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_sfu_login);
+ setTitle(R.string.sfu_lab_login);
+
+ WebView web = findViewById(R.id.sfu_login_webview);
+ CookieManager cm = CookieManager.getInstance();
+ cm.setAcceptCookie(true);
+ cm.setAcceptThirdPartyCookies(web, true);
+
+ web.getSettings().setJavaScriptEnabled(true);
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
+ String url = request.getUrl().toString();
+ if (url.contains("/issues/") && !url.contains("login")) {
+ setResult(RESULT_OK);
+ finish();
+ return true;
+ }
+ return false;
+ }
+ });
+ web.loadUrl(BackendEndpoints.ISSUES_BASE + "/");
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java
index 6622f95..93ec116 100644
--- a/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java
@@ -1,13 +1,16 @@
package com.foxx.androidcast.sfu;
+import android.content.Context;
+
+import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastConfig;
/**
- * Hidden gate for SFU / WebRTC relay experiments (post-alpha).
- * Not exposed in release settings while {@link CastConfig#ALPHA_FEATURE_FREEZE} is on.
+ * Gate for SFU / WebRTC relay experiments (post-alpha).
+ * Available when dev SFU relay is enabled, even under {@link CastConfig#ALPHA_FEATURE_FREEZE}.
*/
public final class SfuRelayGate {
- /** SFU preview enabled — Janus 1.4.1 deployed on cast02, signaling on cast01. */
+ /** SFU preview enabled — Janus deployed on cast02, signaling on cast01. */
public static final boolean PREVIEW_ENABLED = true;
/** REST base path for SFU rooms/join API (relative to app root). */
@@ -24,8 +27,15 @@ public final class SfuRelayGate {
private SfuRelayGate() {}
- public static boolean isAvailable() {
- return PREVIEW_ENABLED && !CastConfig.ALPHA_FEATURE_FREEZE;
+ /** Dev/lab gate — does not require lifting global alpha freeze. */
+ public static boolean isAvailable(Context context) {
+ if (!PREVIEW_ENABLED) {
+ return false;
+ }
+ if (context != null && AppPreferences.isDevSfuRelayEnabled(context)) {
+ return true;
+ }
+ return !CastConfig.ALPHA_FEATURE_FREEZE;
}
/** Returns the full HTTP URL for the SFU health endpoint. */
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java
new file mode 100644
index 0000000..490d1a0
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java
@@ -0,0 +1,379 @@
+package com.foxx.androidcast.sfu;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.json.JSONObject;
+import org.webrtc.AudioSource;
+import org.webrtc.AudioTrack;
+import org.webrtc.Camera2Enumerator;
+import org.webrtc.CameraEnumerator;
+import org.webrtc.CameraVideoCapturer;
+import org.webrtc.DefaultVideoDecoderFactory;
+import org.webrtc.DefaultVideoEncoderFactory;
+import org.webrtc.EglBase;
+import org.webrtc.IceCandidate;
+import org.webrtc.MediaConstraints;
+import org.webrtc.PeerConnection;
+import org.webrtc.PeerConnectionFactory;
+import org.webrtc.RtpReceiver;
+import org.webrtc.SdpObserver;
+import org.webrtc.SessionDescription;
+import org.webrtc.SurfaceTextureHelper;
+import org.webrtc.SurfaceViewRenderer;
+import org.webrtc.VideoSource;
+import org.webrtc.VideoTrack;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** libwebrtc publish/subscribe sessions against Janus VideoRoom via REST + WS trickle. */
+public final class SfuRtcSession implements PeerConnection.Observer {
+ public interface Callback {
+ void onLog(String line);
+
+ void onPublisherId(int publisherId);
+
+ void onError(String message);
+ }
+
+ private static final String TAG = "SfuRtcSession";
+ private static PeerConnectionFactory factory;
+ private static EglBase eglBase;
+
+ private final Context appContext;
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private final Callback callback;
+ private PeerConnection peerConnection;
+ private JanusWebSocketClient janusWs;
+ private VideoSource videoSource;
+ private VideoTrack localVideo;
+ private CameraVideoCapturer capturer;
+ private SurfaceTextureHelper textureHelper;
+ private SurfaceViewRenderer localRenderer;
+ private SurfaceViewRenderer remoteRenderer;
+
+ public SfuRtcSession(Context context, Callback callback) {
+ this.appContext = context.getApplicationContext();
+ this.callback = callback;
+ }
+
+ public static synchronized void ensureFactory(Context context) {
+ if (factory != null) {
+ return;
+ }
+ PeerConnectionFactory.InitializationOptions init =
+ PeerConnectionFactory.InitializationOptions.builder(context)
+ .setEnableInternalTracer(false)
+ .createInitializationOptions();
+ PeerConnectionFactory.initialize(init);
+ eglBase = EglBase.create();
+ factory = PeerConnectionFactory.builder()
+ .setVideoEncoderFactory(new DefaultVideoEncoderFactory(
+ eglBase.getEglBaseContext(), true, true))
+ .setVideoDecoderFactory(new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()))
+ .createPeerConnectionFactory();
+ }
+
+ public static EglBase.Context eglContext() {
+ return eglBase != null ? eglBase.getEglBaseContext() : null;
+ }
+
+ public void attachLocalRenderer(SurfaceViewRenderer renderer) {
+ localRenderer = renderer;
+ if (localVideo != null) {
+ localVideo.addSink(renderer);
+ }
+ }
+
+ public void attachRemoteRenderer(SurfaceViewRenderer renderer) {
+ remoteRenderer = renderer;
+ }
+
+ 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");
+ }
+ });
+ }
+
+ public void subscribe(int roomId, int feedId) {
+ executor.execute(() -> {
+ try {
+ ensureFactory(appContext);
+ SfuSignalingClient api = new SfuSignalingClient(appContext);
+ peerConnection = createPeerConnection();
+ peerConnection.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO,
+ new org.webrtc.RtpTransceiver.RtpTransceiverInit(
+ org.webrtc.RtpTransceiver.RtpTransceiverDirection.RECV_ONLY));
+ peerConnection.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO,
+ new org.webrtc.RtpTransceiver.RtpTransceiverInit(
+ org.webrtc.RtpTransceiver.RtpTransceiverDirection.RECV_ONLY));
+ SessionDescription offer = createOffer(peerConnection);
+ peerConnection.setLocalDescription(simpleSdpObserver("local"), offer);
+ JSONObject join = api.joinSubscriber(roomId, feedId, offer.type.canonicalForm(), offer.description);
+ applyJoinResponse(join);
+ log("Subscriber watching feed " + feedId);
+ } catch (Exception e) {
+ Log.e(TAG, "subscribe", e);
+ callback.onError(e.getMessage() != null ? e.getMessage() : "subscribe failed");
+ }
+ });
+ }
+
+ public void stop() {
+ executor.execute(() -> {
+ if (janusWs != null) {
+ 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;
+ }
+ if (videoSource != null) {
+ videoSource.dispose();
+ videoSource = null;
+ }
+ if (localVideo != null) {
+ localVideo.dispose();
+ localVideo = null;
+ }
+ if (peerConnection != null) {
+ peerConnection.close();
+ peerConnection = null;
+ }
+ });
+ }
+
+ private void applyJoinResponse(JSONObject join) throws org.json.JSONException {
+ long sessionId = join.getLong("session_id");
+ long handleId = join.getLong("handle_id");
+ if (join.has("publisher_id") && !join.isNull("publisher_id")) {
+ callback.onPublisherId(join.getInt("publisher_id"));
+ }
+ JSONObject jsep = join.getJSONObject("jsep");
+ SessionDescription answer = new SessionDescription(
+ SessionDescription.Type.fromCanonicalForm(jsep.getString("type")),
+ jsep.getString("sdp"));
+ peerConnection.setRemoteDescription(simpleSdpObserver("remote"), answer);
+ janusWs = new JanusWebSocketClient();
+ janusWs.connect(sessionId, handleId, new JanusWebSocketClient.Listener() {
+ @Override
+ public void onTrickle(JSONObject candidate) {
+ if (candidate.optBoolean("completed")) {
+ return;
+ }
+ IceCandidate ice = new IceCandidate(
+ candidate.optString("sdpMid"),
+ candidate.optInt("sdpMLineIndex"),
+ candidate.optString("candidate"));
+ peerConnection.addIceCandidate(ice);
+ }
+
+ @Override
+ public void onFailure(String message) {
+ callback.onError(message);
+ }
+ });
+ }
+
+ 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());
+ PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(ice);
+ config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
+ PeerConnection pc = factory.createPeerConnection(config, this);
+ if (pc == null) {
+ throw new IllegalStateException("PeerConnection unavailable");
+ }
+ return pc;
+ }
+
+ private void startCameraCapture() {
+ CameraEnumerator enumerator = new Camera2Enumerator(appContext);
+ String device = null;
+ for (String name : enumerator.getDeviceNames()) {
+ if (enumerator.isFrontFacing(name)) {
+ device = name;
+ break;
+ }
+ }
+ if (device == null && enumerator.getDeviceNames().length > 0) {
+ device = enumerator.getDeviceNames()[0];
+ }
+ if (device == null) {
+ throw new IllegalStateException("No camera");
+ }
+ capturer = enumerator.createCapturer(device, null);
+ textureHelper = SurfaceTextureHelper.create("SfuCap", eglBase.getEglBaseContext());
+ videoSource = factory.createVideoSource(capturer.isScreencast());
+ capturer.initialize(textureHelper, appContext, videoSource.getCapturerObserver());
+ capturer.startCapture(1280, 720, 30);
+ localVideo = factory.createVideoTrack("video0", videoSource);
+ if (localRenderer != null) {
+ localVideo.addSink(localRenderer);
+ }
+ }
+
+ private SessionDescription createOffer(PeerConnection pc) {
+ final SessionDescription[] holder = new SessionDescription[1];
+ final Exception[] error = new Exception[1];
+ MediaConstraints mc = new MediaConstraints();
+ pc.createOffer(new SdpObserver() {
+ @Override
+ public void onCreateSuccess(SessionDescription sessionDescription) {
+ holder[0] = sessionDescription;
+ synchronized (holder) {
+ holder.notifyAll();
+ }
+ }
+
+ @Override
+ public void onCreateFailure(String s) {
+ error[0] = new IllegalStateException(s);
+ synchronized (holder) {
+ holder.notifyAll();
+ }
+ }
+
+ @Override
+ public void onSetSuccess() {}
+
+ @Override
+ public void onSetFailure(String s) {}
+ }, mc);
+ synchronized (holder) {
+ while (holder[0] == null && error[0] == null) {
+ try {
+ holder.wait(15_000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("offer interrupted");
+ }
+ }
+ }
+ if (error[0] != null) {
+ throw new IllegalStateException(error[0].getMessage());
+ }
+ if (holder[0] == null) {
+ throw new IllegalStateException("offer timeout");
+ }
+ return holder[0];
+ }
+
+ private SdpObserver simpleSdpObserver(String label) {
+ return new SdpObserver() {
+ @Override
+ public void onCreateSuccess(SessionDescription sessionDescription) {}
+
+ @Override
+ public void onSetSuccess() {
+ log(label + " SDP set");
+ }
+
+ @Override
+ public void onCreateFailure(String s) {
+ callback.onError(label + " create: " + s);
+ }
+
+ @Override
+ public void onSetFailure(String s) {
+ callback.onError(label + " set: " + s);
+ }
+ };
+ }
+
+ private void log(String msg) {
+ callback.onLog(msg);
+ }
+
+ @Override
+ public void onIceCandidate(IceCandidate iceCandidate) {
+ if (janusWs == null) {
+ return;
+ }
+ try {
+ JSONObject c = new JSONObject();
+ c.put("candidate", iceCandidate.sdp);
+ c.put("sdpMid", iceCandidate.sdpMid);
+ c.put("sdpMLineIndex", iceCandidate.sdpMLineIndex);
+ janusWs.sendTrickle(c);
+ } catch (org.json.JSONException e) {
+ Log.w(TAG, "local trickle", e);
+ }
+ }
+
+ @Override
+ public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}
+
+ @Override
+ public void onAddStream(org.webrtc.MediaStream mediaStream) {}
+
+ @Override
+ public void onSignalingChange(PeerConnection.SignalingState signalingState) {}
+
+ @Override
+ public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
+ log("ICE " + iceConnectionState);
+ }
+
+ @Override
+ public void onIceConnectionReceivingChange(boolean b) {}
+
+ @Override
+ public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
+ if (iceGatheringState == PeerConnection.IceGatheringState.COMPLETE && janusWs != null) {
+ try {
+ janusWs.sendTrickle(new JSONObject().put("completed", true));
+ } catch (org.json.JSONException ignored) {}
+ }
+ }
+
+ @Override
+ public void onRemoveStream(org.webrtc.MediaStream mediaStream) {}
+
+ @Override
+ public void onDataChannel(org.webrtc.DataChannel dataChannel) {}
+
+ @Override
+ public void onRenegotiationNeeded() {}
+
+ @Override
+ public void onAddTrack(RtpReceiver rtpReceiver, org.webrtc.MediaStream[] mediaStreams) {
+ org.webrtc.MediaStreamTrack track = rtpReceiver.track();
+ if (track instanceof VideoTrack && remoteRenderer != null) {
+ ((VideoTrack) track).addSink(remoteRenderer);
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuSignalingClient.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuSignalingClient.java
new file mode 100644
index 0000000..f1c62b5
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuSignalingClient.java
@@ -0,0 +1,128 @@
+package com.foxx.androidcast.sfu;
+
+import android.content.Context;
+
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+/** REST client for ac-ms-sfu-signaling join/rooms API. */
+public final class SfuSignalingClient {
+ private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+ private static final int TIMEOUT_MS = 30_000;
+
+ private final OkHttpClient http;
+ private final String cookieHeader;
+
+ public SfuSignalingClient(Context context) {
+ this.http = new OkHttpClient.Builder()
+ .connectTimeout(TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
+ .readTimeout(TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
+ .build();
+ this.cookieHeader = SfuAuthStore.cookieHeader(context);
+ }
+
+ public JSONObject health() throws IOException {
+ HttpURLConnection conn = (HttpURLConnection) new URL(SfuEndpoints.healthUrl()).openConnection();
+ conn.setConnectTimeout(TIMEOUT_MS);
+ conn.setReadTimeout(TIMEOUT_MS);
+ conn.setRequestMethod("GET");
+ int code = conn.getResponseCode();
+ InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
+ String body = readStream(in);
+ conn.disconnect();
+ try {
+ JSONObject json = new JSONObject(body);
+ if (code >= 400) {
+ throw new IOException(json.optString("error", "HTTP " + code));
+ }
+ return json;
+ } catch (org.json.JSONException e) {
+ throw new IOException(e);
+ }
+ }
+
+ public JSONObject createRoom(String name, String purpose) throws IOException {
+ JSONObject body = new JSONObject();
+ try {
+ body.put("name", name);
+ body.put("purpose", purpose != null ? purpose : "screen_cast");
+ } catch (org.json.JSONException e) {
+ throw new IOException(e);
+ }
+ return postJson(SfuEndpoints.roomsUrl(), body);
+ }
+
+ public JSONObject joinPublisher(int roomId, String offerType, String offerSdp) throws IOException {
+ return join(roomId, "publisher", 0, offerType, offerSdp);
+ }
+
+ public JSONObject joinSubscriber(int roomId, int feedId, String offerType, String offerSdp) throws IOException {
+ return join(roomId, "subscriber", feedId, offerType, offerSdp);
+ }
+
+ private JSONObject join(int roomId, String role, int feedId, String offerType, String offerSdp)
+ throws IOException {
+ JSONObject body = new JSONObject();
+ JSONObject jsep = new JSONObject();
+ try {
+ jsep.put("type", offerType);
+ jsep.put("sdp", offerSdp);
+ body.put("room_id", roomId);
+ body.put("role", role);
+ if (feedId > 0) {
+ body.put("feed_id", feedId);
+ }
+ body.put("jsep", jsep);
+ } catch (org.json.JSONException e) {
+ throw new IOException(e);
+ }
+ return postJson(SfuEndpoints.joinUrl(), body);
+ }
+
+ private JSONObject postJson(String url, JSONObject body) throws IOException {
+ Request.Builder req = new Request.Builder()
+ .url(url)
+ .post(RequestBody.create(body.toString(), JSON));
+ if (cookieHeader != null && !cookieHeader.isEmpty()) {
+ req.header("Cookie", cookieHeader);
+ }
+ try (Response resp = http.newCall(req.build()).execute()) {
+ String text = resp.body() != null ? resp.body().string() : "";
+ JSONObject json = text.isEmpty() ? new JSONObject() : new JSONObject(text);
+ if (!resp.isSuccessful()) {
+ throw new IOException(json.optString("error", "HTTP " + resp.code()));
+ }
+ if (!json.optBoolean("ok", false)) {
+ throw new IOException(json.optString("error", "request failed"));
+ }
+ return json;
+ } catch (org.json.JSONException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static String readStream(InputStream in) throws IOException {
+ if (in == null) {
+ return "";
+ }
+ byte[] buf = new byte[4096];
+ StringBuilder sb = new StringBuilder();
+ int n;
+ while ((n = in.read(buf)) >= 0) {
+ sb.append(new String(buf, 0, n, StandardCharsets.UTF_8));
+ }
+ in.close();
+ return sb.toString();
+ }
+}
diff --git a/app/src/main/res/layout/activity_dev_settings_panel.xml b/app/src/main/res/layout/activity_dev_settings_panel.xml
index 14f50b9..a044ae6 100644
--- a/app/src/main/res/layout/activity_dev_settings_panel.xml
+++ b/app/src/main/res/layout/activity_dev_settings_panel.xml
@@ -417,6 +417,20 @@
android:layout_marginTop="12dp"
android:text="@string/dev_usb_tether_transport" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_sfu_login.xml b/app/src/main/res/layout/activity_sfu_login.xml
new file mode 100644
index 0000000..c07d33b
--- /dev/null
+++ b/app/src/main/res/layout/activity_sfu_login.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 714ef2d..a8218d7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -29,7 +29,7 @@
UDP (default, best effort)
TCP (reliable fallback)
QUIC experimental (Cronet)
- WebRTC (stub, future)
+ WebRTC / SFU (dev lab)
Calibration test pattern
Camera (rear Camera2)
Camera source (requires Android 10+)
@@ -265,6 +265,23 @@
Wired HDMI uses the OS display stack, not the cast protocol. If no second screen appears in system Display settings, adapters cannot be fixed in-app. See docs/USB_HDMI_CAST.md.
External display capture policy (future)
Show USB-tether transport in cast settings (stub, roadmap E)
+ Enable Janus SFU relay lab (libwebrtc M1)
+ Open SFU lab
+ SFU lab
+ SFU lab disabled — enable in developer settings
+ Sign in (issues console)
+ Signed in — SFU API ready
+ Sign in required for room create/join
+ Janus room id
+ Publisher feed id
+ Create room
+ Publish camera
+ Subscribe
+ Stop
+ Local preview
+ Remote viewer
+ Enter Janus room id
+ Enter room id and publisher feed id
External display detected — use system mirror or Wi‑Fi cast to receiver
USB tether (experimental)
ADB over Wi‑Fi (debug)
diff --git a/app/src/test/java/com/foxx/androidcast/sfu/SfuEndpointsTest.java b/app/src/test/java/com/foxx/androidcast/sfu/SfuEndpointsTest.java
new file mode 100644
index 0000000..c248dac
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/sfu/SfuEndpointsTest.java
@@ -0,0 +1,28 @@
+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.junit.Test;
+
+public class SfuEndpointsTest {
+
+ @Test
+ public void apiBase_usesAppsHost() {
+ assertTrue(SfuEndpoints.apiBase().startsWith("https://"));
+ assertTrue(SfuEndpoints.apiBase().contains("apps.f0xx.org"));
+ assertTrue(SfuEndpoints.apiBase().endsWith("/app/androidcast_project/sfu/api"));
+ }
+
+ @Test
+ public void webSocket_isSecure() {
+ assertTrue(SfuEndpoints.webSocketUrl().startsWith("wss://"));
+ assertTrue(SfuEndpoints.webSocketUrl().contains("/sfu/ws"));
+ }
+
+ @Test
+ public void joinUrl_underApiBase() {
+ assertEquals(SfuEndpoints.apiBase() + "/join", SfuEndpoints.joinUrl());
+ }
+}
diff --git a/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java b/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java
index 2113efa..7608b85 100644
--- a/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java
+++ b/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java
@@ -1,14 +1,52 @@
package com.foxx.androidcast.sfu;
-import static org.junit.Assert.*;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import android.content.Context;
+
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.CastConfig;
+
+import org.junit.After;
+import org.junit.Before;
import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+@RunWith(RobolectricTestRunner.class)
public class SfuRelayGateTest {
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = RuntimeEnvironment.getApplication();
+ AppPreferences.setDevSfuRelayEnabled(context, false);
+ }
+
+ @After
+ public void tearDown() {
+ AppPreferences.setDevSfuRelayEnabled(context, false);
+ }
@Test
public void previewEnabled_isTrue() {
- assertTrue("SfuRelayGate.PREVIEW_ENABLED should be true", SfuRelayGate.PREVIEW_ENABLED);
+ assertTrue(SfuRelayGate.PREVIEW_ENABLED);
+ }
+
+ @Test
+ public void isAvailable_falseWhenDevOffAndAlphaFreeze() {
+ AppPreferences.setDevSfuRelayEnabled(context, false);
+ assertTrue(CastConfig.ALPHA_FEATURE_FREEZE);
+ assertFalse(SfuRelayGate.isAvailable(context));
+ }
+
+ @Test
+ public void isAvailable_trueWhenDevSfuOn() {
+ AppPreferences.setDevSfuRelayEnabled(context, true);
+ assertTrue(SfuRelayGate.isAvailable(context));
}
@Test
@@ -41,7 +79,7 @@ public class SfuRelayGateTest {
@Test
public void healthUrl_trailingSlash() {
String url = SfuRelayGate.healthUrl("https://apps.f0xx.org/");
- /* Path should not have a double-slash before /app/androidcast_project */
- assertFalse("Double-slash found in path", url.contains("org//app"));
+ assertFalse(url.contains("org//app"));
}
}
+