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:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/com/foxx/androidcast/sfu/SfuAuthStore.java
Normal file
31
app/src/main/java/com/foxx/androidcast/sfu/SfuAuthStore.java
Normal 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, "");
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/com/foxx/androidcast/sfu/SfuEndpoints.java
Normal file
34
app/src/main/java/com/foxx/androidcast/sfu/SfuEndpoints.java
Normal 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;
|
||||
}
|
||||
}
|
||||
204
app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java
Normal file
204
app/src/main/java/com/foxx/androidcast/sfu/SfuLabActivity.java
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 + "/");
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
379
app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java
Normal file
379
app/src/main/java/com/foxx/androidcast/sfu/SfuRtcSession.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
114
app/src/main/res/layout/activity_sfu_lab.xml
Normal file
114
app/src/main/res/layout/activity_sfu_lab.xml
Normal 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>
|
||||
10
app/src/main/res/layout/activity_sfu_login.xml
Normal file
10
app/src/main/res/layout/activity_sfu_login.xml
Normal 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>
|
||||
@@ -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 Wi‑Fi cast to receiver</string>
|
||||
<string name="transport_usb_tether">USB tether (experimental)</string>
|
||||
<string name="dev_adb_wifi_section">ADB over Wi‑Fi (debug)</string>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user