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

SFU M2: MediaProjection screen share publish in lab.

Add ScreenCapturerAndroid path with foreground service, ICE servers from
SFU health API (coturn), and unit tests for SfuIceServers parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-08-07 22:45:52 +02:00
parent f7d9c0632f
commit 3e170646f0
8 changed files with 317 additions and 47 deletions

View File

@@ -160,6 +160,11 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaProjection" /> android:foregroundServiceType="mediaProjection" />
<service
android:name=".sfu.SfuLabForegroundService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<service <service
android:name=".receiver.ReceiverCastService" android:name=".receiver.ReceiverCastService"
android:exported="false" android:exported="false"

View File

@@ -0,0 +1,77 @@
package com.foxx.androidcast.sfu;
import org.json.JSONArray;
import org.json.JSONObject;
import org.webrtc.PeerConnection;
import java.util.ArrayList;
import java.util.List;
/** ICE server list from SFU health API (coturn + public STUN). */
public final class SfuIceServers {
private SfuIceServers() {}
public static List<PeerConnection.IceServer> defaults() {
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());
return ice;
}
public static List<PeerConnection.IceServer> fromHealth(JSONObject health) {
if (health == null) {
return defaults();
}
JSONObject sfu = health.optJSONObject("sfu");
if (sfu == null) {
return defaults();
}
JSONArray servers = sfu.optJSONArray("ice_servers");
if (servers == null || servers.length() == 0) {
return defaults();
}
List<PeerConnection.IceServer> ice = new ArrayList<>();
for (int i = 0; i < servers.length(); i++) {
JSONObject entry = servers.optJSONObject(i);
if (entry == null) {
continue;
}
PeerConnection.IceServer server = parseEntry(entry);
if (server != null) {
ice.add(server);
}
}
return ice.isEmpty() ? defaults() : ice;
}
private static PeerConnection.IceServer parseEntry(JSONObject entry) {
Object urlsRaw = entry.opt("urls");
List<String> urls = new ArrayList<>();
if (urlsRaw instanceof JSONArray arr) {
for (int i = 0; i < arr.length(); i++) {
String u = arr.optString(i, "").trim();
if (!u.isEmpty()) {
urls.add(u);
}
}
} else {
String u = entry.optString("urls", "").trim();
if (!u.isEmpty()) {
urls.add(u);
}
}
if (urls.isEmpty()) {
return null;
}
PeerConnection.IceServer.Builder builder = PeerConnection.IceServer.builder(urls);
String user = entry.optString("username", entry.optString("user", ""));
String pass = entry.optString("credential", entry.optString("password", ""));
if (!user.isEmpty()) {
builder.setUsername(user);
}
if (!pass.isEmpty()) {
builder.setPassword(pass);
}
return builder.createIceServer();
}
}

View File

@@ -2,6 +2,7 @@ package com.foxx.androidcast.sfu;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.media.projection.MediaProjectionManager;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
@@ -20,10 +21,12 @@ import com.foxx.androidcast.R;
import org.webrtc.SurfaceViewRenderer; import org.webrtc.SurfaceViewRenderer;
/** Dev-only SFU lab: libwebrtc publish/subscribe to Janus VideoRoom (M1). */ /** Dev-only SFU lab: libwebrtc publish/subscribe to Janus VideoRoom (M1 camera, M2 screen). */
public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.Callback { public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.Callback {
private static final int REQ_LOGIN = 9108; private static final int REQ_LOGIN = 9108;
private int pendingScreenRoomId;
private SurfaceViewRenderer localVideo; private SurfaceViewRenderer localVideo;
private SurfaceViewRenderer remoteVideo; private SurfaceViewRenderer remoteVideo;
private TextView logView; private TextView logView;
@@ -37,6 +40,16 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
refreshAuthStatus(); refreshAuthStatus();
}); });
private final ActivityResultLauncher<Intent> screenCaptureLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
if (result.getResultCode() != RESULT_OK || result.getData() == null) {
Toast.makeText(this, R.string.sfu_lab_screen_denied, Toast.LENGTH_SHORT).show();
return;
}
SfuLabForegroundService.start(this);
session.publishScreenShare(pendingScreenRoomId, result.getData());
});
public static void openIfAvailable(Context context) { public static void openIfAvailable(Context context) {
if (!SfuRelayGate.isAvailable(context)) { if (!SfuRelayGate.isAvailable(context)) {
Toast.makeText(context, R.string.sfu_lab_unavailable, Toast.LENGTH_LONG).show(); Toast.makeText(context, R.string.sfu_lab_unavailable, Toast.LENGTH_LONG).show();
@@ -83,6 +96,7 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
findViewById(R.id.btn_sfu_create_room).setOnClickListener(v -> createRoom()); findViewById(R.id.btn_sfu_create_room).setOnClickListener(v -> createRoom());
findViewById(R.id.btn_sfu_publish).setOnClickListener(v -> publish()); findViewById(R.id.btn_sfu_publish).setOnClickListener(v -> publish());
findViewById(R.id.btn_sfu_publish_screen).setOnClickListener(v -> publishScreen());
findViewById(R.id.btn_sfu_subscribe).setOnClickListener(v -> subscribe()); findViewById(R.id.btn_sfu_subscribe).setOnClickListener(v -> subscribe());
findViewById(R.id.btn_sfu_stop).setOnClickListener(v -> stopSession()); findViewById(R.id.btn_sfu_stop).setOnClickListener(v -> stopSession());
@@ -93,6 +107,7 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
@Override @Override
protected void onDestroy() { protected void onDestroy() {
stopSession(); stopSession();
SfuLabForegroundService.stop(this);
localVideo.release(); localVideo.release();
remoteVideo.release(); remoteVideo.release();
super.onDestroy(); super.onDestroy();
@@ -146,6 +161,21 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
session.publishCamera(roomId); session.publishCamera(roomId);
} }
private void publishScreen() {
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;
}
pendingScreenRoomId = roomId;
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
screenCaptureLauncher.launch(mgr.createScreenCaptureIntent());
}
private void subscribe() { private void subscribe() {
int roomId = parseInt(((EditText) findViewById(R.id.edit_sfu_room_id)).getText().toString(), 0); 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); int feedId = parseInt(((EditText) findViewById(R.id.edit_sfu_feed_id)).getText().toString(), 0);
@@ -164,6 +194,7 @@ public class SfuLabActivity extends AppCompatActivity implements SfuRtcSession.C
if (session != null) { if (session != null) {
session.stop(); session.stop();
} }
SfuLabForegroundService.stop(this);
} }
private static int parseInt(String raw, int fallback) { private static int parseInt(String raw, int fallback) {

View File

@@ -0,0 +1,66 @@
package com.foxx.androidcast.sfu;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import androidx.annotation.Nullable;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.R;
/**
* Foreground service holder for SFU lab MediaProjection screen capture (Android 10+).
*/
public final class SfuLabForegroundService extends Service {
private static volatile boolean running;
public static boolean isRunning() {
return running;
}
public static void start(Context context) {
Intent intent = new Intent(context, SfuLabForegroundService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent);
} else {
context.startService(intent);
}
}
public static void stop(Context context) {
context.stopService(new Intent(context, SfuLabForegroundService.class));
}
@Override
public void onCreate() {
super.onCreate();
CastNotifications.createChannels(this);
running = true;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
startForeground(
CastNotifications.ID_SENDER + 200,
CastNotifications.sender(
this,
getString(R.string.sfu_lab_screen_active),
SfuLabForegroundService.class));
return START_STICKY;
}
@Override
public void onDestroy() {
running = false;
super.onDestroy();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
}

View File

@@ -1,6 +1,7 @@
package com.foxx.androidcast.sfu; package com.foxx.androidcast.sfu;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.util.Log; import android.util.Log;
import org.json.JSONObject; import org.json.JSONObject;
@@ -17,14 +18,17 @@ import org.webrtc.MediaConstraints;
import org.webrtc.PeerConnection; import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory; import org.webrtc.PeerConnectionFactory;
import org.webrtc.RtpReceiver; import org.webrtc.RtpReceiver;
import org.webrtc.ScreenCapturerAndroid;
import org.webrtc.SdpObserver; import org.webrtc.SdpObserver;
import org.webrtc.SessionDescription; import org.webrtc.SessionDescription;
import org.webrtc.SurfaceTextureHelper; import org.webrtc.SurfaceTextureHelper;
import org.webrtc.SurfaceViewRenderer; import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoCapturer;
import org.webrtc.VideoSource; import org.webrtc.VideoSource;
import org.webrtc.VideoTrack; import org.webrtc.VideoTrack;
import java.util.ArrayList; import android.media.projection.MediaProjection;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -50,7 +54,7 @@ public final class SfuRtcSession implements PeerConnection.Observer {
private JanusWebSocketClient janusWs; private JanusWebSocketClient janusWs;
private VideoSource videoSource; private VideoSource videoSource;
private VideoTrack localVideo; private VideoTrack localVideo;
private CameraVideoCapturer capturer; private VideoCapturer capturer;
private SurfaceTextureHelper textureHelper; private SurfaceTextureHelper textureHelper;
private SurfaceViewRenderer localRenderer; private SurfaceViewRenderer localRenderer;
private SurfaceViewRenderer remoteRenderer; private SurfaceViewRenderer remoteRenderer;
@@ -93,28 +97,12 @@ public final class SfuRtcSession implements PeerConnection.Observer {
} }
public void publishCamera(int roomId) { public void publishCamera(int roomId) {
executor.execute(() -> { executor.execute(() -> runPublish(roomId, this::startCameraCapture));
try { }
ensureFactory(appContext);
SfuSignalingClient api = new SfuSignalingClient(appContext); /** M2: publish device screen via MediaProjection (matches browser getDisplayMedia lab path). */
startCameraCapture(); public void publishScreenShare(int roomId, Intent projectionData) {
peerConnection = createPeerConnection(); executor.execute(() -> runPublish(roomId, () -> startScreenCapture(projectionData)));
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) { public void subscribe(int roomId, int feedId) {
@@ -122,7 +110,7 @@ public final class SfuRtcSession implements PeerConnection.Observer {
try { try {
ensureFactory(appContext); ensureFactory(appContext);
SfuSignalingClient api = new SfuSignalingClient(appContext); SfuSignalingClient api = new SfuSignalingClient(appContext);
peerConnection = createPeerConnection(); peerConnection = createPeerConnection(loadIceServers(api));
peerConnection.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, peerConnection.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO,
new org.webrtc.RtpTransceiver.RtpTransceiverInit( new org.webrtc.RtpTransceiver.RtpTransceiverInit(
org.webrtc.RtpTransceiver.RtpTransceiverDirection.RECV_ONLY)); org.webrtc.RtpTransceiver.RtpTransceiverDirection.RECV_ONLY));
@@ -147,19 +135,7 @@ public final class SfuRtcSession implements PeerConnection.Observer {
janusWs.close(); janusWs.close();
janusWs = null; janusWs = null;
} }
if (capturer != null) { disposeCapturer();
try {
capturer.stopCapture();
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
capturer.dispose();
capturer = null;
}
if (textureHelper != null) {
textureHelper.dispose();
textureHelper = null;
}
if (videoSource != null) { if (videoSource != null) {
videoSource.dispose(); videoSource.dispose();
videoSource = null; videoSource = null;
@@ -175,6 +151,29 @@ public final class SfuRtcSession implements PeerConnection.Observer {
}); });
} }
private void runPublish(int roomId, Runnable startCapture) {
try {
ensureFactory(appContext);
SfuSignalingClient api = new SfuSignalingClient(appContext);
startCapture.run();
peerConnection = createPeerConnection(loadIceServers(api));
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");
}
}
private void applyJoinResponse(JSONObject join) throws org.json.JSONException { private void applyJoinResponse(JSONObject join) throws org.json.JSONException {
long sessionId = join.getLong("session_id"); long sessionId = join.getLong("session_id");
long handleId = join.getLong("handle_id"); long handleId = join.getLong("handle_id");
@@ -207,10 +206,16 @@ public final class SfuRtcSession implements PeerConnection.Observer {
}); });
} }
private PeerConnection createPeerConnection() { private List<PeerConnection.IceServer> loadIceServers(SfuSignalingClient api) {
List<PeerConnection.IceServer> ice = new ArrayList<>(); try {
ice.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()); return SfuIceServers.fromHealth(api.health());
ice.add(PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer()); } catch (Exception e) {
Log.w(TAG, "ICE from health failed, using defaults", e);
return SfuIceServers.defaults();
}
}
private PeerConnection createPeerConnection(List<PeerConnection.IceServer> ice) {
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(ice); PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(ice);
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
PeerConnection pc = factory.createPeerConnection(config, this); PeerConnection pc = factory.createPeerConnection(config, this);
@@ -236,16 +241,50 @@ public final class SfuRtcSession implements PeerConnection.Observer {
throw new IllegalStateException("No camera"); throw new IllegalStateException("No camera");
} }
capturer = enumerator.createCapturer(device, null); capturer = enumerator.createCapturer(device, null);
startVideoCapture(false, 1280, 720, 30);
}
private void startScreenCapture(Intent projectionData) {
if (projectionData == null) {
throw new IllegalStateException("Screen capture consent missing");
}
capturer = new ScreenCapturerAndroid(projectionData, new MediaProjection.Callback() {
@Override
public void onStop() {
log("MediaProjection stopped");
stop();
}
});
startVideoCapture(true, 1280, 720, 15);
}
private void startVideoCapture(boolean screencast, int width, int height, int fps) {
textureHelper = SurfaceTextureHelper.create("SfuCap", eglBase.getEglBaseContext()); textureHelper = SurfaceTextureHelper.create("SfuCap", eglBase.getEglBaseContext());
videoSource = factory.createVideoSource(capturer.isScreencast()); videoSource = factory.createVideoSource(screencast);
capturer.initialize(textureHelper, appContext, videoSource.getCapturerObserver()); capturer.initialize(textureHelper, appContext, videoSource.getCapturerObserver());
capturer.startCapture(1280, 720, 30); capturer.startCapture(width, height, fps);
localVideo = factory.createVideoTrack("video0", videoSource); localVideo = factory.createVideoTrack("video0", videoSource);
if (localRenderer != null) { if (localRenderer != null) {
localVideo.addSink(localRenderer); localVideo.addSink(localRenderer);
} }
} }
private void disposeCapturer() {
if (capturer != null) {
try {
capturer.stopCapture();
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
capturer.dispose();
capturer = null;
}
if (textureHelper != null) {
textureHelper.dispose();
textureHelper = null;
}
}
private SessionDescription createOffer(PeerConnection pc) { private SessionDescription createOffer(PeerConnection pc) {
final SessionDescription[] holder = new SessionDescription[1]; final SessionDescription[] holder = new SessionDescription[1];
final Exception[] error = new Exception[1]; final Exception[] error = new Exception[1];

View File

@@ -61,11 +61,17 @@
android:id="@+id/btn_sfu_publish" android:id="@+id/btn_sfu_publish"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1" android:layout_weight="1"
android:text="@string/sfu_lab_publish" /> android:text="@string/sfu_lab_publish" />
</LinearLayout> </LinearLayout>
<Button
android:id="@+id/btn_sfu_publish_screen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/sfu_lab_publish_screen" />
<Button <Button
android:id="@+id/btn_sfu_subscribe" android:id="@+id/btn_sfu_subscribe"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -265,7 +265,7 @@
<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_sfu_relay">Enable Janus SFU relay lab (libwebrtc M1/M2)</string>
<string name="dev_open_sfu_lab">Open SFU lab</string> <string name="dev_open_sfu_lab">Open SFU lab</string>
<string name="sfu_lab_title">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_unavailable">SFU lab disabled — enable in developer settings</string>
@@ -276,6 +276,9 @@
<string name="sfu_lab_feed_id">Publisher feed 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_create_room">Create room</string>
<string name="sfu_lab_publish">Publish camera</string> <string name="sfu_lab_publish">Publish camera</string>
<string name="sfu_lab_publish_screen">Publish screen share</string>
<string name="sfu_lab_screen_active">SFU screen share active</string>
<string name="sfu_lab_screen_denied">Screen capture permission denied</string>
<string name="sfu_lab_subscribe">Subscribe</string> <string name="sfu_lab_subscribe">Subscribe</string>
<string name="sfu_lab_stop">Stop</string> <string name="sfu_lab_stop">Stop</string>
<string name="sfu_lab_local">Local preview</string> <string name="sfu_lab_local">Local preview</string>

View File

@@ -0,0 +1,43 @@
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.json.JSONObject;
import org.junit.Test;
import org.webrtc.PeerConnection;
import java.util.List;
public class SfuIceServersTest {
@Test
public void defaults_includeGoogleStun() {
List<PeerConnection.IceServer> ice = SfuIceServers.defaults();
assertEquals(2, ice.size());
assertTrue(ice.get(0).urls.get(0).contains("stun:"));
}
@Test
public void fromHealth_parsesTurnEntry() throws Exception {
JSONObject health = new JSONObject("""
{"sfu":{"ice_servers":[
{"urls":"stun:stun.l.google.com:19302"},
{"urls":"turn:10.7.16.237:3478?transport=udp","username":"acturn","credential":"secret"}
]}}
""");
List<PeerConnection.IceServer> ice = SfuIceServers.fromHealth(health);
assertEquals(2, ice.size());
assertTrue(ice.get(1).urls.get(0).startsWith("turn:"));
assertEquals("acturn", ice.get(1).username);
assertEquals("secret", ice.get(1).password);
}
@Test
public void fromHealth_emptyFallsBackToDefaults() throws Exception {
JSONObject health = new JSONObject("{\"sfu\":{}}");
List<PeerConnection.IceServer> ice = SfuIceServers.fromHealth(health);
assertFalse(ice.isEmpty());
assertTrue(ice.get(0).urls.get(0).contains("stun:"));
}
}