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