diff --git a/apps/android/README.md b/apps/android/README.md index 762cbe99..7fc7eb87 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -91,6 +91,8 @@ CI 构建 Debug、Release 和设备测试 APK,执行 Debug / Release Lint、 授权后首页会自动连接服务器,绿色“已连接服务器”表示信令登录完成;失败时可点击状态重试。设置页显示本机 ID。电脑端保持在线,在 Android 首页填写 9 位设备 ID,首次点击“连接”后在底部弹层输入密码,可选择加密保存密码。成功保存后,点击最近连接或再次输入相同 ID 会直接使用该服务器下保存的密码,重启应用后仍然有效;密码无法读取或被远端拒绝时会重新显示输入弹层,新密码仅在连接成功后替换,取消或连接失败不会覆盖原密码。自托管服务器从“设置 → 服务器”展开修改主机名与端口,不能填写 `https://`、路径或完整 URL。默认是 `api.crossdesk.cn:9099`。 +最近连接卡片与 iOS 一样,在设备 ID 旁显示已保存密码的钥匙图标和在线/离线状态,在线设备优先,同一状态内保留原来的最近连接顺序。授权并登录服务器后,通过身份信令订阅 `recent_connections_presence`,接收完整快照与 `presence_update`;每 30 秒刷新,状态超过 60 秒未更新则显示离线。断线、切换服务器或进入后台会清除在线状态,重连后等待新的快照。自托管服务需要支持相同的设备在线状态协议。 + 证书信任来自 Android 默认 TrustManager 导出的根证书。JNI 设置证书文件路径后,MiniRTC 的 Android 分支通过 `SSL_CTX_load_verify_locations` 显式加载,并校验证书链及 DNS / IP 主机名;不会跳过 TLS 验证。Android 应用进程带有 `AT_SECURE` 标记,不能依赖 OpenSSL 的默认路径加载器读取 `SSL_CERT_FILE`,否则会出现所有服务器均无法建立信任链的问题。自托管服务器应使用受系统信任的证书。当前没有在应用内导入自签名证书的入口。 身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。 @@ -134,7 +136,7 @@ python3 test/run_native.py 测试源码、资源和检查入口统一位于 `test/`: - `test/native/`:本机 C++ 协议、帧队列与画面延时回归,由 `test/run_native.py` 编译运行。 -- `test/android/java/`:20 项非 UI 的 Android 功能回归,覆盖需要 Android API 或 JNI 的逻辑。Gradle 的 `androidTest` 源集显式引用此目录。 +- `test/android/java/`:非 UI 的 Android 功能回归,覆盖需要 Android API 或 JNI 的逻辑,包括设备在线状态刷新、过期与信令重连。Gradle 的 `androidTest` 源集显式引用此目录。 - `test/android/assets/tls/`:本机环回 TLS 测试证书,仅进入测试 APK。 - `test/verify_apk.py`:发布 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、许可证完整性和源码资料检查。 diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index afe918c8..5efa291e 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -41,6 +41,13 @@ public final class MainActivity extends Activity implements NativeSession.Listen private String announcementServer=""; private SharedPreferences preferences; private RecentConnections history; + private final RecentConnectionPresence recentPresence=new RecentConnectionPresence(); + private LinearLayout recentGrid; + private final Runnable presenceTick=new Runnable(){public void run(){ + if(!recentPresence.isConnected())return; + recentPresence.maintain(android.os.SystemClock.elapsedRealtime()); + mainHandler.postDelayed(this,1000); + }}; private RemotePreviewStore previews; private RemotePreviewStore.Capture previewCapture; private boolean previewCopying,updatingPreviewSwitch; @@ -74,6 +81,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this); pageHost=new PageHost(this);setContentView(pageHost); preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099); + recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);}; + recentPresence.changed=this::renderRecentConnections; remoteVersionCheck=new RemoteVersionCheck(this::showRemoteUpdate); previews=RemotePreviewStore.get(this);previews.addListener(previewChanged);previews.enforceConsent(); announcementInbox=new AnnouncementInbox(new java.io.File(getNoBackupFilesDir(),"AnnouncementReads")); @@ -98,7 +107,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen } if(root!=null)hideKeyboard();page=nextPage; signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null; - previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear(); + previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null; canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND); root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1)); root.setOnApplyWindowInsetsListener((view,insets)->{ @@ -163,20 +172,25 @@ public final class MainActivity extends Activity implements NativeSession.Listen Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run(); remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}}); LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p); - JSONArray records=history.list(serverKey());LinearLayout heading=row();heading.addView(text("最近连接",20,true),new LinearLayout.LayoutParams(0,-2,1)); + JSONArray records=history.list(serverKey());recentPresence.watch(records,android.os.SystemClock.elapsedRealtime());LinearLayout heading=row();heading.addView(text("最近连接",20,true),new LinearLayout.LayoutParams(0,-2,1)); if(records.length()>0){TextView count=text(records.length()+" 台设备",12,false);count.setTextColor(SECONDARY);heading.addView(count);}recent.addView(heading); if(records.length()==0){ LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12); TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint); }else{ - ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout grid=column();scroll.addView(grid); - for(int i=0;ii)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(grid,12);} + ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid);renderRecentConnections(); } ensureSignaling(); if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show(); } private String id(){return remote.getText().toString().replace(" ","");} static String formatId(String value){String digits=value.replaceAll("[^0-9]","");if(digits.length()>9)digits=digits.substring(0,9);return digits.replaceAll("(.{3})(?=.)","$1 ");} + private void renderRecentConnections(){ + if(recentGrid==null)return; + JSONArray records=recentPresence.ordered(history.list(serverKey()),android.os.SystemClock.elapsedRealtime()); + recentGrid.removeAllViews();previewCards.clear(); + for(int i=0;ii)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(recentGrid,12);} + } private View recentCard(JSONObject item){ if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform"); LinearLayout card=column();ui.card(card,14);FrameLayout preview=new FrameLayout(this){@Override protected void onMeasure(int w,int h){super.onMeasure(w,MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(w)*9/16,MeasureSpec.EXACTLY));}}; @@ -186,8 +200,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen String server=serverKey();Runnable load=()->{if(!image.isAttachedToWindow())return;Object binding=new Object();image.setTag(binding);image.setImageDrawable(null);image.setVisibility(View.GONE);label.setVisibility(View.VISIBLE); previews.load(server,id,bitmap->{if(image.getTag()!=binding||!image.isAttachedToWindow()){if(bitmap!=null)bitmap.recycle();return;}if(bitmap!=null){image.setImageBitmap(bitmap);image.setVisibility(View.VISIBLE);label.setVisibility(View.GONE);}});};previewCards.add(load);image.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener(){public void onViewAttachedToWindow(View view){load.run();}public void onViewDetachedFromWindow(View view){image.setTag(null);}});if(image.isAttachedToWindow())load.run(); LinearLayout caption=column();caption.setPadding(dp(9),dp(9),dp(9),dp(9));TextView name=text(item.optString("name",id),12,true);name.setSingleLine();name.setEllipsize(TextUtils.TruncateAt.END);caption.addView(name);ui.gap(caption,4); - TextView detail=text("ID "+id+(item.optBoolean("remember")?" · 已保存密码":""),10,false);detail.setTextColor(SECONDARY);caption.addView(detail);card.addView(caption); - card.setContentDescription("连接 "+item.optString("name",id));card.setClickable(true);card.setFocusable(true);card.setOnClickListener(v->{remote.setText(formatId(id));promptConnection(id);}); + LinearLayout details=row();TextView detail=text("ID "+id,10,false);detail.setTypeface(Typeface.create("sans-serif",Typeface.NORMAL));detail.setTextColor(SECONDARY);detail.setSingleLine();detail.setEllipsize(TextUtils.TruncateAt.END);details.addView(detail,new LinearLayout.LayoutParams(0,-2,1)); + if(item.optBoolean("remember")){View key=ui.icon("key",SECONDARY);key.setContentDescription("已保存密码");LinearLayout.LayoutParams keySize=ui.size(10,10);keySize.leftMargin=dp(4);details.addView(key,keySize);} + boolean online=recentPresence.isOnline(id,android.os.SystemClock.elapsedRealtime());String availability=online?"在线":"离线";TextView presence=text(availability,10,true);presence.setSingleLine();presence.setTextColor(online?GREEN:SECONDARY);LinearLayout.LayoutParams presenceSize=ui.size(-2,-2);presenceSize.leftMargin=dp(5);details.addView(presence,presenceSize);caption.addView(details);card.addView(caption); + card.setContentDescription("连接 "+item.optString("name",id)+",ID "+id+(item.optBoolean("remember")?",已保存密码":"")+","+availability);card.setClickable(true);card.setFocusable(true);card.setOnClickListener(v->{remote.setText(formatId(id));promptConnection(id);}); card.setOnLongClickListener(v->{new AlertDialog.Builder(this).setTitle(item.optString("name",id)).setItems(new String[]{"连接","删除记录"},(d,w)->{if(w==0)promptConnection(id);else{history.delete(serverKey(),id);home("");}}).show();return true;});return card; } private void promptConnection(String id){ @@ -386,24 +402,35 @@ public final class MainActivity extends Activity implements NativeSession.Listen renderSignaling();signaling.start(); } private void stopSignaling(){ + mainHandler.removeCallbacks(presenceTick);recentPresence.setConnected(false,android.os.SystemClock.elapsedRealtime()); announcementInbox.setConnected(false); if(signaling!=null)signaling.close(); signaling=null;signalingServer="";localIdentity="";signalState=-1;signalError="";renderSignaling(); } private void renderSignaling(){ boolean consent=preferences.getBoolean("networkConsent",false); + boolean connected=consent&&signalError.isEmpty()&&signalState==1; String label;int tint,background; if(!consent){label="等待隐私授权";tint=0xFFB76A00;background=0xFFFFEBD2;} else if(!signalError.isEmpty()){label=signalError.contains("TLS")?"证书校验失败 · 点击重试":"连接失败 · 点击重试";tint=RED;background=0xFFFFE8E6;} - else if(signalState==1){label="✓ 已连接服务器";tint=GREEN;background=0xFFE5F4E8;} + else if(connected){label="已连接服务器";tint=GREEN;background=0xFFE5F4E8;} else{label=signalState==0?"正在连接服务器…":signalState==4?"正在重连服务器…":"未连接服务器 · 点击重试";tint=0xFFB76A00;background=0xFFFFEBD2;} - if(signalBadge!=null){signalBadge.setText(label);signalBadge.setTextColor(tint);signalBadge.setBackground(ui.background(background,20));signalBadge.setContentDescription(signalError.isEmpty()?label:signalError+",点击重试");} + if(signalBadge!=null){ + android.graphics.drawable.Drawable icon=connected?getDrawable(R.drawable.ic_check_circle):null; + if(icon!=null){int size=dp(14);icon.setBounds(0,0,size,size);} + signalBadge.setCompoundDrawablesRelative(icon,null,null,null);signalBadge.setCompoundDrawablePadding(dp(6)); + signalBadge.setText(label);signalBadge.setTextColor(tint);signalBadge.setBackground(ui.background(background,20));signalBadge.setContentDescription(signalError.isEmpty()?label:signalError+",点击重试"); + } if(identitySummary!=null)identitySummary.setText(!consent?"完成隐私授权后登记本机身份":!localIdentity.isEmpty()?"本机 ID "+localIdentity:!signalError.isEmpty()?signalError:"正在获取本机 ID…"); } @Override public void signaling(int state,String deviceId){ signalState=state;localIdentity=deviceId;signalError="";renderSignaling(); + long now=android.os.SystemClock.elapsedRealtime();recentPresence.watch(history.list(serverKey()),now); + recentPresence.setConnected(foreground&&preferences.getBoolean("networkConsent",false)&&state==1&&!deviceId.isEmpty(),now); + mainHandler.removeCallbacks(presenceTick);if(recentPresence.isConnected())mainHandler.postDelayed(presenceTick,1000); if(preferences.getBoolean("networkConsent",false)){announcementInbox.configure(host,port,deviceId);announcementInbox.setConnected(state==1);} } + @Override public void presence(JSONObject message){recentPresence.receive(message,android.os.SystemClock.elapsedRealtime());} @Override public void status(String value){if(session!=null&&status!=null)status.setText(value);} @Override public void connected(){ // Transport readiness can be reported again after ICE changes its selected path. diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java index b2f32af9..7662e4b3 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java @@ -70,6 +70,9 @@ final class MobileUi { case "folder":path(c,2,19,2,5,9,5,12,8,22,8,22,19,2,19);break; case "chart":path(c,3,3,3,21,22,21);path(c,7,17,7,13);path(c,12,17,12,8);path(c,18,17,18,4);break; case "lock":c.drawRoundRect(5,10,19,22,2,2,paint);c.drawArc(8,2,16,15,180,180,false,paint);c.drawPoint(12,16,paint);break; + case "key": + paint.setStyle(Paint.Style.FILL);c.drawCircle(7,8,5,paint);paint.setStyle(Paint.Style.STROKE);paint.setStrokeWidth(4);path(c,10,11,19,20);path(c,15,16,18,13);path(c,18,19,21,16); + paint.setStyle(Paint.Style.FILL);int keyColor=paint.getColor();paint.setColor(Color.WHITE);c.drawCircle(6,7,1.5f,paint);paint.setColor(keyColor);break; case "unlock":c.drawRoundRect(5,10,19,22,2,2,paint);c.drawArc(8,2,16,15,180,150,false,paint);c.drawPoint(12,16,paint);break; case "clipboard":c.drawRoundRect(4,4,20,22,2,2,paint);c.drawRoundRect(8,2,16,7,2,2,paint);break; case "eye":path(c,2,12,6,7,12,5,18,7,22,12,18,17,12,19,6,17,2,12);c.drawCircle(12,12,3,paint);break; diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java index 765dfcf8..d10a0894 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java @@ -5,6 +5,7 @@ import android.os.Handler; import android.os.Looper; import android.util.Base64; import android.view.Surface; +import org.json.JSONArray; import org.json.JSONObject; import java.io.File; import java.nio.charset.StandardCharsets; @@ -28,6 +29,7 @@ final class NativeSession implements AutoCloseable { void clipboard(String text); void statistics(RemoteNetworkStatistics.Snapshot value); default void signaling(int state, String deviceId) { } + default void presence(JSONObject message) { } default void announcement(JSONObject message) { } default void announcementFailed(String requestId) { } default void passwordRejected() { ended("远端密码错误"); } @@ -45,6 +47,8 @@ final class NativeSession implements AutoCloseable { private long handle; private boolean signalReady, joining, connected, started; private int signalState; + private volatile long signalGeneration; + private boolean presenceRequested; private volatile boolean closed; private Surface surface; private volatile AudioPlayer audio; @@ -146,6 +150,8 @@ final class NativeSession implements AutoCloseable { if (type == 1) { int status = event.getInt("status"); if (!event.getBoolean("controller")) { + long generation=event.optLong("generation"); + if(signalGeneration!=generation){signalGeneration=generation;presenceRequested=false;} signalState = status; signalReady = status == 1; if (signalReady && remote.isEmpty()) main.removeCallbacks(timeout); @@ -177,6 +183,10 @@ final class NativeSession implements AutoCloseable { ui(() -> listener.videoSize(width, height)); } else if (type == 7) networkStatistics.receive(new RemoteNetworkStatistics.Report(event),RemoteNetworkStatistics.now()); else if (type == 8 && signalReady) ui(() -> listener.announcement(event)); + else if (type == 9 && signalReady && presenceRequested) { + long generation=event.optLong("generation"); + if(generation==signalGeneration)ui(()->{if(generation==signalGeneration)listener.presence(event);}); + } } catch (Exception error) { if (type == 3) fail("设备身份保存失败,请检查安全存储"); } @@ -197,6 +207,18 @@ final class NativeSession implements AutoCloseable { byte[] bytes=request.toString().getBytes(StandardCharsets.UTF_8); execute(()->{if(!signalReady||identity.isEmpty()||!nAnnouncementRequest(handle,bytes))ui(()->listener.announcementFailed(requestId));}); } + void presenceRequest(JSONArray ids){ + // Own the caller's list before crossing to the serialized RTC executor. + String devices=ids.toString(); + execute(()->{ + if(!signalReady||identity.isEmpty())return; + try{ + JSONObject request=new JSONObject().put("type","recent_connections_presence") + .put("user_id",identity.split("@",2)[0]).put("devices",new JSONArray(devices)).put("subscribe",true); + if(nPresenceRequest(handle,request.toString().getBytes(StandardCharsets.UTF_8)))presenceRequested=true; + }catch(org.json.JSONException ignored){ } + }); + } void surface(Surface value) { execute(() -> { surface = value; if (handle != 0) nSurface(handle, value); }); } void pointer(float x, float y, int flag, int wheel) { execute(() -> { if (connected) nPointer(handle, x, y, flag, wheel); }); } void key(int code, boolean down) { execute(() -> { if (connected) nKey(handle, code, down); }); } @@ -231,6 +253,7 @@ final class NativeSession implements AutoCloseable { private static native void nControl(long handle, int type, int value); private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId); private static native boolean nAnnouncementRequest(long handle,byte[] data); + private static native boolean nPresenceRequest(long handle,byte[] data); private static native void nClipboard(long handle, byte[] data); private static native void nSurface(long handle, Surface surface); private static native void nDestroy(long handle); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RecentConnectionPresence.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RecentConnectionPresence.java new file mode 100644 index 00000000..181faacb --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RecentConnectionPresence.java @@ -0,0 +1,85 @@ +package cn.crossdesk.mobile; + +import org.json.JSONArray; +import org.json.JSONObject; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** Main-thread presence cache, with the same refresh and expiry intervals as iOS. */ +final class RecentConnectionPresence { + private static final long REFRESH_INTERVAL=30_000, MAX_AGE=60_000; + private final Set watched=new LinkedHashSet<>(); + private final Map online=new HashMap<>(); + private final Map updatedAt=new HashMap<>(); + private boolean connected,awaitingSnapshot=true; + private long lastRefresh; + Consumer sender=ids->{}; + Runnable changed=()->{}; + + void watch(JSONArray records,long now){ + Set next=new LinkedHashSet<>(); + for(int i=0;i values=new HashMap<>(); + if(snapshot){ + JSONArray devices=message.optJSONArray("devices");if(devices==null)return; + for(int i=0;i previous=new HashMap<>(online); + if(snapshot){online.clear();updatedAt.clear();awaitingSnapshot=false;} + for(var entry:values.entrySet()){online.put(entry.getKey(),entry.getValue());updatedAt.put(entry.getKey(),now);} + if(!previous.equals(online))changed.run(); + } + private void read(JSONObject device,Map values){ + Object id=device.opt("id"),value=device.opt("online"); + if(id instanceof String&&watched.contains(id)&&value instanceof Boolean)values.put((String)id,(Boolean)value); + } + private void refresh(long now){ + if(!connected)return; + lastRefresh=now;JSONArray ids=new JSONArray();for(String id:watched)ids.put(id);sender.accept(ids); + } + void maintain(long now){ + if(!connected)return; + boolean expired=updatedAt.entrySet().removeIf(entry->{ + if(now-entry.getValue()=REFRESH_INTERVAL)refresh(now); + } + JSONArray ordered(JSONArray records,long now){ + JSONArray result=new JSONArray(); + // Preserve history order within each group, including when all devices are offline. + for(boolean available:new boolean[]{true,false})for(int i=0;i + + diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index 910c2e21..458e86c6 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -57,6 +57,8 @@ struct Session { std::string host, log_path, identity_login, controller_login, remote; int port; std::atomic stopping{false}; + std::atomic signal_generation{0}; + std::atomic signal_status{-1}; std::atomic display{0}; std::atomic surface_ready{false}; std::atomic video_generation{0}; @@ -134,15 +136,23 @@ struct Session { void Signal(SignalStatus status, const char*, size_t, void* user) { auto* ctx = static_cast(user); - ctx->owner->Event(1, {{"controller", ctx->controller}, {"status", status}}); + if (!ctx->controller && ctx->owner->signal_status.exchange(status) != status) + ++ctx->owner->signal_generation; + const auto generation = ctx->owner->signal_generation.load(); + ctx->owner->Event(1, {{"controller", ctx->controller}, {"status", status}, {"generation", generation}}); } void SignalMessage(const char* data, size_t size, void* user) { auto* ctx = static_cast(user); if (ctx->controller || !data || !size || size > 1024 * 1024) return; + const auto generation = ctx->owner->signal_generation.load(); auto message = json::parse(data, data + size, nullptr, false); if (!message.is_object() || !message.contains("type") || !message["type"].is_string()) return; const auto type = message["type"].get(); if (type == "announcements" || type == "announcements_changed") ctx->owner->Event(8, data, size); + else if (type == "presence" || type == "presence_update") { + message["generation"] = generation; + ctx->owner->Event(9, message); + } } void Connection(ConnectionStatus status, const char*, size_t, void* user) { auto* ctx = static_cast(user); @@ -387,6 +397,23 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nAnnouncementRequest)( !message["type"].is_string() || message["type"] != "announcements_list") return false; return SendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0; } +extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPresenceRequest)( + JNIEnv* env, jclass, jlong handle, jbyteArray data) { + auto* s = From(handle); + if (!s || !s->identity || s->stopping || !data) return false; + const auto size = env->GetArrayLength(data); + if (size <= 0 || size > 4096) return false; + std::string bytes(size, '\0'); + env->GetByteArrayRegion(data, 0, size, reinterpret_cast(bytes.data())); + if (env->ExceptionCheck()) return false; + auto message = json::parse(bytes, nullptr, false); + if (!message.is_object() || !message.contains("type") || !message["type"].is_string() || + message["type"] != "recent_connections_presence" || + !message.contains("user_id") || !message["user_id"].is_string() || + !message.contains("devices") || !message["devices"].is_array() || + !message.contains("subscribe") || message["subscribe"] != true) return false; + return SendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0; +} extern "C" JNIEXPORT void JNICALL JNI_METHOD(nClipboard)( JNIEnv* env, jclass, jlong handle, jbyteArray data) { auto* s = From(handle); if (!s || !data) return; diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/ControllerTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/ControllerTest.java index f28eab96..8f537c2a 100644 --- a/apps/android/test/android/java/cn/crossdesk/mobile/ControllerTest.java +++ b/apps/android/test/android/java/cn/crossdesk/mobile/ControllerTest.java @@ -15,6 +15,38 @@ import static org.junit.Assert.*; @RunWith(AndroidJUnit4.class) public class ControllerTest { + @Test public void presenceCallbacksRejectDisconnectedAndPreviousSignalingGeneration()throws Exception{ + var instrumentation=InstrumentationRegistry.getInstrumentation(); + var delivered=new java.util.concurrent.atomic.AtomicInteger(); + NativeSession.Listener listener=new NativeSession.Listener(){ + public void status(String value){}public void connected(){}public void ended(String reason){} + public void data(JSONObject value){}public void videoSize(int width,int height){}public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){} + public void presence(JSONObject value){delivered.incrementAndGet();} + }; + NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"","",listener); + Field workerField=NativeSession.class.getDeclaredField("RTC"),requestedField=NativeSession.class.getDeclaredField("presenceRequested");workerField.setAccessible(true);requestedField.setAccessible(true); + ExecutorService worker=(ExecutorService)workerField.get(null); + var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true); + java.util.function.BiConsumer send=(type,json)->{ + try{event.invoke(session,type,json.getBytes(java.nio.charset.StandardCharsets.UTF_8));}catch(Exception error){throw new RuntimeException(error);} + }; + try{ + send.accept(1,"{\"controller\":false,\"status\":1,\"generation\":1}"); + worker.submit(()->{requestedField.setBoolean(session,true);return null;}).get(5,TimeUnit.SECONDS); + send.accept(9,"{\"type\":\"presence\",\"devices\":[],\"generation\":1}"); + worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertEquals(1,delivered.get()); + send.accept(1,"{\"controller\":false,\"status\":4,\"generation\":2}"); + send.accept(9,"{\"type\":\"presence\",\"devices\":[],\"generation\":1}"); + send.accept(1,"{\"controller\":false,\"status\":1,\"generation\":3}"); + worker.submit(()->{requestedField.setBoolean(session,true);return null;}).get(5,TimeUnit.SECONDS); + send.accept(9,"{\"type\":\"presence\",\"devices\":[],\"generation\":1}"); + worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertEquals(1,delivered.get()); + send.accept(1,"{\"controller\":false,\"status\":1,\"generation\":3}"); + send.accept(9,"{\"type\":\"presence_update\",\"id\":\"123456789\",\"online\":true,\"generation\":3}"); + worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertEquals("Repeated login readiness keeps the current subscription",2,delivered.get()); + }finally{session.close();worker.submit(()->{}).get(5,TimeUnit.SECONDS);} + } + @Test public void repeatedNativeConnectedEventsNotifyAndInitializeOnlyOnce()throws Exception{ var instrumentation=InstrumentationRegistry.getInstrumentation(); var notifications=new java.util.concurrent.atomic.AtomicInteger(); diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/RecentConnectionPresenceTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/RecentConnectionPresenceTest.java new file mode 100644 index 00000000..27242200 --- /dev/null +++ b/apps/android/test/android/java/cn/crossdesk/mobile/RecentConnectionPresenceTest.java @@ -0,0 +1,51 @@ +package cn.crossdesk.mobile; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import java.util.ArrayList; +import static org.junit.Assert.*; + +public class RecentConnectionPresenceTest { + private static JSONArray records(String... ids)throws Exception{ + JSONArray result=new JSONArray();for(String id:ids)result.put(new JSONObject().put("id",id));return result; + } + private static JSONObject device(String id,boolean online)throws Exception{return new JSONObject().put("id",id).put("online",online);} + private static JSONObject snapshot(JSONObject... devices)throws Exception{ + JSONArray values=new JSONArray();for(JSONObject device:devices)values.put(device);return new JSONObject().put("type","presence").put("devices",values); + } + private static JSONObject update(String id,boolean online)throws Exception{return device(id,online).put("type","presence_update");} + + @Test public void snapshotAndUpdatesOnlyAffectWatchedDevicesAndSortStably()throws Exception{ + RecentConnectionPresence presence=new RecentConnectionPresence();JSONArray history=records("111111111","222222222","333333333"); + presence.watch(history,0);presence.setConnected(true,0); + presence.receive(update("111111111",true),1);assertFalse(presence.isOnline("111111111",1)); + presence.receive(snapshot(device("222222222",true),device("333333333",true),device("999999999",true)),2); + assertFalse(presence.isOnline("999999999",2));assertTrue(presence.isOnline("222222222",2)); + JSONArray ordered=presence.ordered(history,2);assertEquals("222222222",ordered.getJSONObject(0).getString("id"));assertEquals("333333333",ordered.getJSONObject(1).getString("id")); + presence.receive(update("222222222",false),3);assertFalse(presence.isOnline("222222222",3)); + assertEquals("333333333",presence.ordered(history,3).getJSONObject(0).getString("id")); + presence.receive(snapshot(device("111111111",true)),4);assertFalse("A full snapshot replaces missing devices",presence.isOnline("333333333",4)); + } + + @Test public void refreshExpiryAndReconnectRequireFreshSnapshot()throws Exception{ + RecentConnectionPresence presence=new RecentConnectionPresence();ArrayList requests=new ArrayList<>();presence.sender=requests::add; + presence.watch(records("111111111"),0);assertTrue(requests.isEmpty());presence.setConnected(true,0);assertEquals(1,requests.size()); + presence.receive(snapshot(device("111111111",true)),0);presence.maintain(29_999);assertEquals(1,requests.size()); + presence.maintain(30_000);assertEquals(2,requests.size());assertTrue(presence.isOnline("111111111",59_999)); + presence.maintain(60_000);assertEquals(3,requests.size());assertFalse(presence.isOnline("111111111",60_000)); + presence.receive(update("111111111",true),60_001);assertTrue(presence.isOnline("111111111",60_001)); + presence.setConnected(false,60_002);presence.receive(snapshot(device("111111111",true)),60_003);presence.maintain(120_000);assertEquals(3,requests.size());assertFalse(presence.isOnline("111111111",120_000)); + presence.setConnected(true,120_001);assertEquals(4,requests.size());presence.receive(update("111111111",true),120_002);assertFalse(presence.isOnline("111111111",120_002)); + presence.receive(snapshot(device("111111111",true)),120_003);assertTrue(presence.isOnline("111111111",120_003)); + } + + @Test public void changedHistoryResubscribesAndMalformedStatesDoNotBecomeOnline()throws Exception{ + RecentConnectionPresence presence=new RecentConnectionPresence();ArrayList requests=new ArrayList<>();presence.sender=requests::add; + presence.watch(records("111111111","111111111"),0);presence.setConnected(true,0);assertEquals(1,requests.get(0).length()); + presence.receive(snapshot(device("111111111",true)),1);presence.watch(records("222222222"),2);assertEquals(2,requests.size());assertFalse(presence.isOnline("111111111",2)); + presence.receive(snapshot(new JSONObject().put("id","222222222").put("online","true")),3);assertFalse(presence.isOnline("222222222",3)); + presence.receive(snapshot(device("222222222",true)),4);presence.receive(new JSONObject().put("type","presence").put("devices","invalid"),5);assertTrue(presence.isOnline("222222222",5)); + presence.watch(records(),6);assertEquals(0,requests.get(2).length());assertFalse(presence.isOnline("222222222",6)); + } +}