1
0
mirror of git://f0xx.org/ac/ac-mobile-android synced 2026-08-12 18:12:13 +03:00

SFU M1: libwebrtc Janus publish/subscribe behind dev gate.

Add stream-webrtc lab activity with REST join + Janus WS trickle,
WebView issues login for session cookies, and developer settings toggle
without lifting ALPHA_FEATURE_FREEZE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-08-07 22:28:03 +02:00
parent 47eb316d17
commit f7d9c0632f
19 changed files with 1238 additions and 15 deletions

View File

@@ -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')
}

View File

@@ -67,6 +67,18 @@
android:label="@string/developer_settings_title"
android:parentActivityName=".MainActivity" />
<activity
android:name=".sfu.SfuLabActivity"
android:exported="false"
android:label="@string/sfu_lab_title"
android:parentActivityName=".DeveloperSettingsActivity" />
<activity
android:name=".sfu.SfuLoginActivity"
android:exported="false"
android:label="@string/sfu_lab_login"
android:parentActivityName=".sfu.SfuLabActivity" />
<activity
android:name=".qr.QrScanActivity"
android:exported="false"

View File

@@ -63,6 +63,8 @@ public final class AppPreferences {
private static final String KEY_DEV_WIRED_DISPLAY_HINTS = "dev_wired_display_hints";
private static final String KEY_DEV_USB_TETHER_TRANSPORT = "dev_usb_tether_transport";
private static final String KEY_DEV_SFU_RELAY = "dev_sfu_relay";
private static final String KEY_DEV_SFU_SESSION_COOKIE = "dev_sfu_session_cookie";
private static final String KEY_DEV_SENDER_RESOLUTION_UI_MODE = "dev_sender_resolution_ui_mode";
private static final String KEY_DEV_EXTERNAL_CAPTURE_POLICY = "dev_external_capture_policy";
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
@@ -386,7 +388,7 @@ public final class AppPreferences {
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
}
s.setCodecHardening(getSenderCodecHardening(context));
clampAlphaTransport(s);
clampAlphaTransport(context, s);
return s;
}
@@ -455,6 +457,24 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_USB_TETHER_TRANSPORT, enabled).apply();
}
/** Developer: Janus SFU relay lab (libwebrtc publish/subscribe). */
public static boolean isDevSfuRelayEnabled(Context context) {
return prefs(context).getBoolean(KEY_DEV_SFU_RELAY, false);
}
public static void setDevSfuRelayEnabled(Context context, boolean enabled) {
prefs(context).edit().putBoolean(KEY_DEV_SFU_RELAY, enabled).apply();
}
/** Optional fallback session cookie if WebView jar is empty. */
public static String getDevSfuSessionCookie(Context context) {
return prefs(context).getString(KEY_DEV_SFU_SESSION_COOKIE, "");
}
public static void setDevSfuSessionCookie(Context context, String cookie) {
prefs(context).edit().putString(KEY_DEV_SFU_SESSION_COOKIE, cookie != null ? cookie : "").apply();
}
/** Default: {@link SenderResolutionUiMode#SEPARATED}. */
public static SenderResolutionUiMode getDevSenderResolutionUiMode(Context context) {
String name = prefs(context).getString(
@@ -759,7 +779,7 @@ public final class AppPreferences {
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
}
s.setCodecHardening(getReceiverCodecHardening(context));
clampAlphaTransport(s);
clampAlphaTransport(context, s);
return s;
}
@@ -835,13 +855,18 @@ public final class AppPreferences {
return fallback;
}
private static void clampAlphaTransport(CastSettings settings) {
private static void clampAlphaTransport(Context context, CastSettings settings) {
if (settings == null || !CastConfig.ALPHA_FEATURE_FREEZE) {
return;
}
if (!CastConfig.isReleaseTransport(settings.getTransport())) {
settings.setTransport(CastConfig.DEFAULT_TRANSPORT);
if (CastConfig.isReleaseTransport(settings.getTransport())) {
return;
}
if (CastConfig.TRANSPORT_WEBRTC.equals(settings.getTransport())
&& isDevSfuRelayEnabled(context)) {
return;
}
settings.setTransport(CastConfig.DEFAULT_TRANSPORT);
}
public static String getEntitlementAccountKey(Context context) {

View File

@@ -245,6 +245,10 @@ public final class CastSettingsBinder {
labelList.add(activity.getString(R.string.transport_usb_tether));
valueList.add(CastConfig.TRANSPORT_USB_TETHER);
}
if (com.foxx.androidcast.sfu.SfuRelayGate.isAvailable(activity)) {
labelList.add(activity.getString(R.string.transport_webrtc));
valueList.add(CastConfig.TRANSPORT_WEBRTC);
}
String[] labels = labelList.toArray(new String[0]);
String[] values = valueList.toArray(new String[0]);
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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, "");
}
}

View File

@@ -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;
}
}

View File

@@ -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<Intent> 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);
});
}
}

View File

@@ -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 + "/");
}
}

View File

@@ -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. */

View File

@@ -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<PeerConnection.IceServer> 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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -417,6 +417,20 @@
android:layout_marginTop="12dp"
android:text="@string/dev_usb_tether_transport" />
<CheckBox
android:id="@+id/check_dev_sfu_relay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_sfu_relay" />
<Button
android:id="@+id/btn_open_sfu_lab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/dev_open_sfu_lab" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -0,0 +1,114 @@
<?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/sfu_lab_title"
android:textAppearance="?attr/textAppearanceHeadline6" />
<TextView
android:id="@+id/text_sfu_lab_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBody2" />
<Button
android:id="@+id/btn_sfu_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/sfu_lab_login" />
<EditText
android:id="@+id/edit_sfu_room_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/sfu_lab_room_id"
android:inputType="number" />
<EditText
android:id="@+id/edit_sfu_feed_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/sfu_lab_feed_id"
android:inputType="number" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/btn_sfu_create_room"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/sfu_lab_create_room" />
<Button
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" />
</LinearLayout>
<Button
android:id="@+id/btn_sfu_subscribe"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/sfu_lab_subscribe" />
<Button
android:id="@+id/btn_sfu_stop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sfu_lab_stop" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/sfu_lab_local" />
<org.webrtc.SurfaceViewRenderer
android:id="@+id/sfu_local_video"
android:layout_width="match_parent"
android:layout_height="180dp"
android:layout_marginTop="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/sfu_lab_remote" />
<org.webrtc.SurfaceViewRenderer
android:id="@+id/sfu_remote_video"
android:layout_width="match_parent"
android:layout_height="180dp"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/text_sfu_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="monospace"
android:textAppearance="?attr/textAppearanceCaption" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,10 @@
<?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">
<WebView
android:id="@+id/sfu_login_webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -29,7 +29,7 @@
<string name="transport_udp">UDP (default, best effort)</string>
<string name="transport_tcp">TCP (reliable fallback)</string>
<string name="transport_quic">QUIC experimental (Cronet)</string>
<string name="transport_webrtc">WebRTC (stub, future)</string>
<string name="transport_webrtc">WebRTC / SFU (dev lab)</string>
<string name="capture_calibration_test">Calibration test pattern</string>
<string name="capture_camera">Camera (rear Camera2)</string>
<string name="capture_camera_unavailable">Camera source (requires Android 10+)</string>
@@ -265,6 +265,23 @@
<string name="dev_wired_display_hint">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.</string>
<string name="dev_external_capture_policy">External display capture policy (future)</string>
<string name="dev_usb_tether_transport">Show USB-tether transport in cast settings (stub, roadmap E)</string>
<string name="dev_sfu_relay">Enable Janus SFU relay lab (libwebrtc M1)</string>
<string name="dev_open_sfu_lab">Open SFU lab</string>
<string name="sfu_lab_title">SFU lab</string>
<string name="sfu_lab_unavailable">SFU lab disabled — enable in developer settings</string>
<string name="sfu_lab_login">Sign in (issues console)</string>
<string name="sfu_lab_authed">Signed in — SFU API ready</string>
<string name="sfu_lab_need_login">Sign in required for room create/join</string>
<string name="sfu_lab_room_id">Janus room id</string>
<string name="sfu_lab_feed_id">Publisher feed id</string>
<string name="sfu_lab_create_room">Create room</string>
<string name="sfu_lab_publish">Publish camera</string>
<string name="sfu_lab_subscribe">Subscribe</string>
<string name="sfu_lab_stop">Stop</string>
<string name="sfu_lab_local">Local preview</string>
<string name="sfu_lab_remote">Remote viewer</string>
<string name="sfu_lab_room_required">Enter Janus room id</string>
<string name="sfu_lab_feed_required">Enter room id and publisher feed id</string>
<string name="wired_display_detected_hint">External display detected — use system mirror or WiFi cast to receiver</string>
<string name="transport_usb_tether">USB tether (experimental)</string>
<string name="dev_adb_wifi_section">ADB over WiFi (debug)</string>

View File

@@ -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());
}
}

View File

@@ -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"));
}
}