mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 17:10:20 +08:00
[feat] add Android device presence and align status icons with iOS
This commit is contained in:
@@ -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 对齐、许可证完整性和源码资料检查。
|
||||
|
||||
|
||||
@@ -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;i<records.length();i+=2){LinearLayout line=row();line.setGravity(Gravity.TOP);grid.addView(line);for(int j=i;j<i+2;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)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;i<records.length();i+=2){LinearLayout line=row();line.setGravity(Gravity.TOP);recentGrid.addView(line);for(int j=i;j<i+2;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> watched=new LinkedHashSet<>();
|
||||
private final Map<String,Boolean> online=new HashMap<>();
|
||||
private final Map<String,Long> updatedAt=new HashMap<>();
|
||||
private boolean connected,awaitingSnapshot=true;
|
||||
private long lastRefresh;
|
||||
Consumer<JSONArray> sender=ids->{};
|
||||
Runnable changed=()->{};
|
||||
|
||||
void watch(JSONArray records,long now){
|
||||
Set<String> next=new LinkedHashSet<>();
|
||||
for(int i=0;i<records.length();i++){
|
||||
JSONObject record=records.optJSONObject(i);
|
||||
if(record!=null&&!record.optString("id").isEmpty())next.add(record.optString("id"));
|
||||
}
|
||||
if(watched.equals(next))return;
|
||||
watched.clear();watched.addAll(next);
|
||||
online.keySet().retainAll(watched);updatedAt.keySet().retainAll(watched);
|
||||
refresh(now);changed.run();
|
||||
}
|
||||
void setConnected(boolean value,long now){
|
||||
if(connected==value)return;
|
||||
connected=value;online.clear();updatedAt.clear();awaitingSnapshot=true;
|
||||
refresh(now);changed.run();
|
||||
}
|
||||
boolean isConnected(){return connected;}
|
||||
boolean isOnline(String id,long now){
|
||||
Long time=updatedAt.get(id);
|
||||
return connected&&!awaitingSnapshot&&Boolean.TRUE.equals(online.get(id))&&time!=null&&now-time<MAX_AGE;
|
||||
}
|
||||
void receive(JSONObject message,long now){
|
||||
if(!connected)return;
|
||||
boolean snapshot="presence".equals(message.optString("type"));
|
||||
Map<String,Boolean> values=new HashMap<>();
|
||||
if(snapshot){
|
||||
JSONArray devices=message.optJSONArray("devices");if(devices==null)return;
|
||||
for(int i=0;i<devices.length();i++){JSONObject device=devices.optJSONObject(i);if(device!=null)read(device,values);}
|
||||
}else{
|
||||
if(!"presence_update".equals(message.optString("type"))||awaitingSnapshot)return;
|
||||
read(message,values);
|
||||
}
|
||||
Map<String,Boolean> 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<String,Boolean> 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()<MAX_AGE)return false;
|
||||
online.remove(entry.getKey());return true;
|
||||
});
|
||||
if(expired)changed.run();
|
||||
if(now-lastRefresh>=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<records.length();i++){
|
||||
JSONObject record=records.optJSONObject(i);
|
||||
if(record!=null&&isOnline(record.optString("id"),now)==available)result.put(record);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="14dp"
|
||||
android:height="14dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF248A3D"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12,2A10,10 0,1 0,12 22A10,10 0,1 0,12 2Z M6.5,11.5L10.3,15.3L17.5,8.1L16.1,6.7L10.3,12.5L7.9,10.1Z" />
|
||||
</vector>
|
||||
@@ -57,6 +57,8 @@ struct Session {
|
||||
std::string host, log_path, identity_login, controller_login, remote;
|
||||
int port;
|
||||
std::atomic<bool> stopping{false};
|
||||
std::atomic<uint64_t> signal_generation{0};
|
||||
std::atomic<int> signal_status{-1};
|
||||
std::atomic<int> display{0};
|
||||
std::atomic<bool> surface_ready{false};
|
||||
std::atomic<uint64_t> video_generation{0};
|
||||
@@ -134,15 +136,23 @@ struct Session {
|
||||
|
||||
void Signal(SignalStatus status, const char*, size_t, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(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<CallbackContext*>(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<std::string>();
|
||||
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<CallbackContext*>(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<jbyte*>(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;
|
||||
|
||||
@@ -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<Integer,String> 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();
|
||||
|
||||
@@ -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<JSONArray> 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<JSONArray> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user