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 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.json:json:20240303'
|
testImplementation 'org.json:json:20240303'
|
||||||
|
testImplementation 'org.robolectric:robolectric:4.11.1'
|
||||||
|
|
||||||
implementation 'com.github.mwiede:jsch:0.2.21'
|
implementation 'com.github.mwiede:jsch:0.2.21'
|
||||||
|
|
||||||
@@ -159,5 +160,9 @@ dependencies {
|
|||||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||||
implementation 'org.slf4j:slf4j-android:1.7.36'
|
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')
|
implementation project(':tunnel')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,18 @@
|
|||||||
android:label="@string/developer_settings_title"
|
android:label="@string/developer_settings_title"
|
||||||
android:parentActivityName=".MainActivity" />
|
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
|
<activity
|
||||||
android:name=".qr.QrScanActivity"
|
android:name=".qr.QrScanActivity"
|
||||||
android:exported="false"
|
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_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_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_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_EXTERNAL_CAPTURE_POLICY = "dev_external_capture_policy";
|
||||||
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
|
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.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
|
||||||
}
|
}
|
||||||
s.setCodecHardening(getSenderCodecHardening(context));
|
s.setCodecHardening(getSenderCodecHardening(context));
|
||||||
clampAlphaTransport(s);
|
clampAlphaTransport(context, s);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +457,24 @@ public final class AppPreferences {
|
|||||||
prefs(context).edit().putBoolean(KEY_DEV_USB_TETHER_TRANSPORT, enabled).apply();
|
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}. */
|
/** Default: {@link SenderResolutionUiMode#SEPARATED}. */
|
||||||
public static SenderResolutionUiMode getDevSenderResolutionUiMode(Context context) {
|
public static SenderResolutionUiMode getDevSenderResolutionUiMode(Context context) {
|
||||||
String name = prefs(context).getString(
|
String name = prefs(context).getString(
|
||||||
@@ -759,7 +779,7 @@ public final class AppPreferences {
|
|||||||
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
|
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
|
||||||
}
|
}
|
||||||
s.setCodecHardening(getReceiverCodecHardening(context));
|
s.setCodecHardening(getReceiverCodecHardening(context));
|
||||||
clampAlphaTransport(s);
|
clampAlphaTransport(context, s);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,13 +855,18 @@ public final class AppPreferences {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void clampAlphaTransport(CastSettings settings) {
|
private static void clampAlphaTransport(Context context, CastSettings settings) {
|
||||||
if (settings == null || !CastConfig.ALPHA_FEATURE_FREEZE) {
|
if (settings == null || !CastConfig.ALPHA_FEATURE_FREEZE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!CastConfig.isReleaseTransport(settings.getTransport())) {
|
if (CastConfig.isReleaseTransport(settings.getTransport())) {
|
||||||
settings.setTransport(CastConfig.DEFAULT_TRANSPORT);
|
return;
|
||||||
}
|
}
|
||||||
|
if (CastConfig.TRANSPORT_WEBRTC.equals(settings.getTransport())
|
||||||
|
&& isDevSfuRelayEnabled(context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.setTransport(CastConfig.DEFAULT_TRANSPORT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getEntitlementAccountKey(Context context) {
|
public static String getEntitlementAccountKey(Context context) {
|
||||||
|
|||||||
@@ -245,6 +245,10 @@ public final class CastSettingsBinder {
|
|||||||
labelList.add(activity.getString(R.string.transport_usb_tether));
|
labelList.add(activity.getString(R.string.transport_usb_tether));
|
||||||
valueList.add(CastConfig.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[] labels = labelList.toArray(new String[0]);
|
||||||
String[] values = valueList.toArray(new String[0]);
|
String[] values = valueList.toArray(new String[0]);
|
||||||
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
|
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
bindDiagPingSection();
|
bindDiagPingSection();
|
||||||
bindSenderResolutionUiModeSection();
|
bindSenderResolutionUiModeSection();
|
||||||
bindWiredDisplaySection();
|
bindWiredDisplaySection();
|
||||||
|
bindSfuLabSection();
|
||||||
bindAdbWifiSection();
|
bindAdbWifiSection();
|
||||||
bindCommercialSection();
|
bindCommercialSection();
|
||||||
}
|
}
|
||||||
@@ -170,10 +171,24 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
bindRemoteAccessSection();
|
bindRemoteAccessSection();
|
||||||
bindSenderResolutionUiModeSection();
|
bindSenderResolutionUiModeSection();
|
||||||
bindWiredDisplaySection();
|
bindWiredDisplaySection();
|
||||||
|
bindSfuLabSection();
|
||||||
bindAdbWifiSection();
|
bindAdbWifiSection();
|
||||||
bindCommercialSection();
|
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() {
|
private void bindAdbWifiSection() {
|
||||||
if (!BuildConfig.DEBUG) {
|
if (!BuildConfig.DEBUG) {
|
||||||
android.view.View section = findViewById(R.id.check_dev_adb_wifi_keeper);
|
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;
|
package com.foxx.androidcast.sfu;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.AppPreferences;
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hidden gate for SFU / WebRTC relay experiments (post-alpha).
|
* Gate for SFU / WebRTC relay experiments (post-alpha).
|
||||||
* Not exposed in release settings while {@link CastConfig#ALPHA_FEATURE_FREEZE} is on.
|
* Available when dev SFU relay is enabled, even under {@link CastConfig#ALPHA_FEATURE_FREEZE}.
|
||||||
*/
|
*/
|
||||||
public final class SfuRelayGate {
|
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;
|
public static final boolean PREVIEW_ENABLED = true;
|
||||||
|
|
||||||
/** REST base path for SFU rooms/join API (relative to app root). */
|
/** REST base path for SFU rooms/join API (relative to app root). */
|
||||||
@@ -24,8 +27,15 @@ public final class SfuRelayGate {
|
|||||||
|
|
||||||
private SfuRelayGate() {}
|
private SfuRelayGate() {}
|
||||||
|
|
||||||
public static boolean isAvailable() {
|
/** Dev/lab gate — does not require lifting global alpha freeze. */
|
||||||
return PREVIEW_ENABLED && !CastConfig.ALPHA_FEATURE_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. */
|
/** 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:layout_marginTop="12dp"
|
||||||
android:text="@string/dev_usb_tether_transport" />
|
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
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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_udp">UDP (default, best effort)</string>
|
||||||
<string name="transport_tcp">TCP (reliable fallback)</string>
|
<string name="transport_tcp">TCP (reliable fallback)</string>
|
||||||
<string name="transport_quic">QUIC experimental (Cronet)</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_calibration_test">Calibration test pattern</string>
|
||||||
<string name="capture_camera">Camera (rear Camera2)</string>
|
<string name="capture_camera">Camera (rear Camera2)</string>
|
||||||
<string name="capture_camera_unavailable">Camera source (requires Android 10+)</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_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_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_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="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="transport_usb_tether">USB tether (experimental)</string>
|
||||||
<string name="dev_adb_wifi_section">ADB over Wi‑Fi (debug)</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;
|
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.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.robolectric.RobolectricTestRunner;
|
||||||
|
import org.robolectric.RuntimeEnvironment;
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner.class)
|
||||||
public class SfuRelayGateTest {
|
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
|
@Test
|
||||||
public void previewEnabled_isTrue() {
|
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
|
@Test
|
||||||
@@ -41,7 +79,7 @@ public class SfuRelayGateTest {
|
|||||||
@Test
|
@Test
|
||||||
public void healthUrl_trailingSlash() {
|
public void healthUrl_trailingSlash() {
|
||||||
String url = SfuRelayGate.healthUrl("https://apps.f0xx.org/");
|
String url = SfuRelayGate.healthUrl("https://apps.f0xx.org/");
|
||||||
/* Path should not have a double-slash before /app/androidcast_project */
|
assertFalse(url.contains("org//app"));
|
||||||
assertFalse("Double-slash found in path", url.contains("org//app"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user