diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index b92a4684..d2e316da 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -95,9 +95,9 @@ jobs: - name: Verify APKs and native protocol working-directory: apps/android run: | - python3 -B scripts/verify_apk.py app/build/outputs/apk/debug/app-debug.apk - python3 -B scripts/verify_apk.py app/build/outputs/apk/release/app-release-unsigned.apk - python3 -B scripts/test_protocol.py + python3 -B test/verify_apk.py app/build/outputs/apk/debug/app-debug.apk + python3 -B test/verify_apk.py app/build/outputs/apk/release/app-release-unsigned.apk + python3 -B test/run_native.py "${ANDROID_HOME}/build-tools/36.0.0/apksigner" verify app/build/outputs/apk/debug/app-debug.apk "${ANDROID_HOME}/build-tools/36.0.0/apksigner" verify app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk diff --git a/apps/android/README.md b/apps/android/README.md index d5ab1678..762cbe99 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -5,11 +5,11 @@ ## 当前功能 - 官方或自托管 WSS 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权或进入后台会关闭连接。 -- H.264(OpenH264)和 AV1(dav1d)软件解码,独立显示线程将 NV12 转 RGBA 后提交 Android 原生 Surface;只保留最新待显示帧,避免 Surface 等待阻塞解码。压缩视频帧使用 MiniRTC 原有的通用解码队列,暂未限制排队长度或等待时间。 +- 优先使用 MediaCodec 硬件解码 H.264 / AV1,硬件缓冲区直接提交 Surface;设备无对应硬件、分辨率超限或运行失败时,按原编码格式回退到 OpenH264 / dav1d。软件输出由独立显示线程将 NV12 转 RGBA;只保留最新待显示帧。MiniRTC 原有的通用压缩帧解码队列保持不变。 - 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。 - 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键、双指滚轮,以及外接键盘、鼠标。 - 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。 -- 根据 Android 软件解码和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 +- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。 - 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。 @@ -21,7 +21,7 @@ - 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。 - “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。 -当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令、捏合缩放或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。H.264 / AV1 尚未使用 MediaCodec 硬件解码,高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。 +当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令、捏合缩放或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。 ## 构建 @@ -125,27 +125,33 @@ CI 构建 Debug、Release 和设备测试 APK,执行 Debug / Release Lint、 "$ANDROID_HOME/platform-tools/adb" install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk "$ANDROID_HOME/platform-tools/adb" shell am instrument -w -r cn.crossdesk.mobile.test/androidx.test.runner.AndroidJUnitRunner "$ANDROID_HOME/platform-tools/adb" uninstall cn.crossdesk.mobile.test -python3 scripts/verify_apk.py app/build/outputs/apk/debug/app-debug.apk -python3 scripts/test_protocol.py +python3 test/verify_apk.py app/build/outputs/apk/debug/app-debug.apk +python3 test/run_native.py ``` 以上流程覆盖安装应用并只卸载测试包,保留已有应用数据;多台设备同时连接时,在每个 `adb` 命令中加上 `-s <设备序列号>`。`connectedDebugAndroidTest` 可能卸载被测应用及其数据,不用于已有连接记录或密码的设备。 -自动化测试覆盖键盘映射、身份加密及服务器隔离、原生 Peer 的重复启动/取消,以及未授权时不启动信令、授权后自动连接、后台断开/前台重连、服务器切换及首页状态更新。布局测试验证密码弹层、设置导航、密码与连接记录的隔离、已保存密码直接重连及页面重启后的读取、损坏凭据回退输入、密码错误后修改并在成功时保存、悬浮菜单/键盘的横竖屏边界,以及断开确认流程;会话 UI 使用本地测试对象和连接状态回调驱动,不代表真实电脑互通验证。TLS 回归测试在真实 Android 应用进程内使用本机环回 TLS 服务,覆盖受信证书成功、未知 CA 拒绝以及主机名不匹配拒绝;测试证书和私钥仅打包进测试 APK。`verify_apk.py` 检查 APK 必须带有 arm64 原生库、许可证,以及 ELF 和 ZIP 的 16 KB 对齐。 +测试源码、资源和检查入口统一位于 `test/`: -画面设置与网络统计测试覆盖能力检测、请求/确认/拒绝/超时、快速修改与乱序响应、无效数据、请求编号回绕、码率单位、RTT 平滑、实际提交帧计数、空闲/过期数据和切屏重置;UI 测试检查设置回退及统计更新时面板不重建、不跳动。JNI 测试验证发送失败的反馈与网络/画面采样回调。`test_protocol.py` 在本机编译 Android 实际使用的消息编码函数,并用桌面端共享解析器验证画面设置报文,同时检查 MiniRTC 分项统计的字段映射。 +- `test/native/`:本机 C++ 协议、帧队列与画面延时回归,由 `test/run_native.py` 编译运行。 +- `test/android/java/`:20 项非 UI 的 Android 功能回归,覆盖需要 Android API 或 JNI 的逻辑。Gradle 的 `androidTest` 源集显式引用此目录。 +- `test/android/assets/tls/`:本机环回 TLS 测试证书,仅进入测试 APK。 +- `test/verify_apk.py`:发布 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、许可证完整性和源码资料检查。 -2026-10-01 初始本地验证:NDK r28c 原生编译、Gradle Debug APK、Lint 零错误、Android 15 arm64 的 4 KB / 16 KB 模拟器各 4 项测试,以及共享 wire / keyboard-state 协议测试。iOS 布局适配后,Android 15 / 16 KB 模拟器的 7 项测试通过,完成首页、密码、设置、悬浮控制栏和横屏键盘截图检查。修复 TLS 根证书加载后,新增的 3 项环回 TLS 测试与原有测试合计 10 项通过;修复前可稳定复现受信证书握手失败。当时尚未连接真实电脑。 +设备回归仅保留 TLS 证书校验、凭据加密及服务器隔离、原生连接生命周期、键盘映射、画面设置状态、网络采样、公告与预览数据持久化、版本兼容判断。预览存储测试使用生成的纯色位图,不采集屏幕或视频画面。 -此前使用 Retroid Pocket 2S(Android 11)通过 P2P 连接 Mac 的延时测试包含 Android 专用解码队列保护。该策略现已撤销,历史延时数据不能代表当前版本;后续统一进行跨平台解码队列优化与性能验证。4K / 60 fps 超过该设备的软件解码能力时仍可能产生积压。声音、触控、屏幕切换、剪贴板完整互通及其他 Android 版本真机仍需进一步验证。 +UI 验证统一由人工完成,不维护页面导航、布局、动画、悬浮窗和截图采集的自动化用例。许可证和构建资料的完整性由 `test/verify_apk.py` 检查。 -公告功能新增 12 项测试,覆盖摘要/正文分页、全量未读计数、读取和删除的持久保存、版本更新、服务器及身份隔离、乱序/重复/畸形回复、超时、磁盘写入失败、安全链接和原生信令回调;UI 测试验证角标、详情、下拉刷新、左滑删除、空态和重试。2026-10-01 在 Android 15 arm64 / 16 KB 模拟器上,包含连接、密码、悬浮菜单、画面设置和网络统计的 47 项回归测试通过;APK 对齐检查、原生协议测试及 Lint(零错误)通过。模拟器连接实际默认服务器后,成功加载 1 条公告并显示正确的未读数量;详情及删除操作使用独立测试数据验证。 +## MediaCodec 编解码 -远程预览功能新增 11 项测试:默认关闭与授权约束、保存与跨实例读取、服务器隔离、重复连接、清除及删除与未完成截图的竞争、清除失败后重试、损坏图片、居中裁剪,以及开关/清除/撤回授权的页面流程。实际 Surface 截图测试验证 640×360 输出、排除本地覆盖控件、每次连接仅保存一次、首页展示及重启后读取。2026-10-01 在 Android 15 arm64 / 16 KB 模拟器上,58 项预览、公告、连接、密码、布局、画面设置与网络统计回归测试通过。模拟器连接本机 Mac 实测生成 640×360 JPEG 预览,关闭开关后预览目录已清除,原有连接记录和已保存密码保留。 +实现参照 MiniRTC 的 NVCodec 分层:`src/media/mediacodec/` 负责硬件后端、能力查询和 JNI/Surface 资源;`src/media/video/encode/mediacodec/` 与 `src/media/video/decode/mediacodec/` 分别接入编码器、解码器接口,并负责同编码格式的软件回退。Android 工程直接编译这些源码。通用 API 仅需启用 `hardware_acceleration` 和 `native_video_output`。JavaVM 由 Android JNI 适配层在 `JNI_OnLoad` 中设置到后端私有实现;该桥接不安装为 SDK 头文件,也不导出为动态库符号。当前能力查询和 SurfaceTexture 创建依赖 JNI,缺少 JavaVM 时仍可使用软件编解码。独立输出线程在画面停止更新后仍会取出最后一帧,空闲时休眠。 -页面转场新增 7 项测试,覆盖进入/返回的首帧及中间帧位置、正常播放结束、连续导航与系统返回、同页刷新、后台及尺寸变化中断、转场中的输入拦截,以及关闭系统动画后直接切换。会话测试确保视频 Surface 所在页面保持不透明、不建立硬件缓存层,只对其上方的普通页面做淡入淡出,避免视频 Surface 的透明度兼容问题。2026-10-01 在 Android 15 arm64 / 16 KB 模拟器上,包含已有功能的 65 项回归测试通过;转场中间帧截图、Lint(零错误)和 APK 对齐检查通过。 +- H.264 / AV1 由系统能力查询选择硬件组件,支持编码码率调整、请求关键帧和分辨率变化。编码输入为 8 位 NV12,硬件需支持 NV12 / I420 ByteBuffer 输入;不满足时回退 OpenH264 / SVT-AV1。当前应用仍是控制端,库的编码能力不代表已实现安卓被控端。 +- 解码原生输出为可保留/释放的 MediaCodec 缓冲区,显示线程直接提交 Surface。切换显示器、销毁 Surface 和切回软件输出时解除旧 Surface 绑定。缓冲区在解码器重建或销毁后不可再次显示;未显示的缓冲区自动归还。 +- 关闭原生输出时,支持将硬件 NV12 / I420 输出按行距、填充高度和裁剪范围转换为紧凑 NV12;未知布局回退软件。 +- Retroid Pocket 2S 的 `OMX.sprd.av1.decoder` 实际加载软件 dav1d,虽被系统标成硬件组件,仍明确排除;AV1 使用软件回退。H.264 可使用 `OMX.sprd.h264.encoder` / `OMX.sprd.h264.decoder`。 -分段选项动画接入后,页面转场、布局与画面设置的 30 项相关回归测试通过,覆盖设置保存、能力变化、应答排序、拒绝及超时回退。模拟器录屏检查确认鼠标控制、画面偏好的选中底块连续滑动,悬浮控制栏布局保持稳定;Lint 零错误,APK 对齐检查通过。 +AV1 硬件路径尚无对应实机验证;Retroid 上使用 dav1d 软件解码。 ## 开源资料 diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 217936be..f490c489 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -19,6 +19,8 @@ android { } sourceSets.main.jniLibs.srcDirs = [layout.buildDirectory.dir('native')] sourceSets.main.assets.srcDirs += [layout.buildDirectory.dir('generated/notices')] + sourceSets.androidTest.java.srcDirs = ['../test/android/java'] + sourceSets.androidTest.assets.srcDirs = ['../test/android/assets'] buildTypes.release { minifyEnabled false } packaging.jniLibs.useLegacyPackaging = false } diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/ControllerTest.java b/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/ControllerTest.java deleted file mode 100644 index a927b296..00000000 --- a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/ControllerTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package cn.crossdesk.mobile; - -import android.content.Context; -import android.content.Intent; -import android.view.KeyEvent; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; -import org.json.JSONObject; -import org.junit.Test; -import org.junit.runner.RunWith; -import java.lang.reflect.Field; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; -import static org.junit.Assert.*; - -@RunWith(AndroidJUnit4.class) -public class ControllerTest { - @Test public void announcementsUseIdentitySignalingAndReportNativeSendFailure()throws Exception{ - var instrumentation=InstrumentationRegistry.getInstrumentation();var reply=new java.util.concurrent.atomic.AtomicReference();var failed=new java.util.concurrent.atomic.AtomicReference(); - 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 announcement(JSONObject value){reply.set(value);}public void announcementFailed(String id){failed.set(id);} - }; - NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"","",listener); - Field workerField=NativeSession.class.getDeclaredField("RTC");workerField.setAccessible(true);ExecutorService worker=(ExecutorService)workerField.get(null); - var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true); - byte[] changed="{\"type\":\"announcements_changed\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8); - try{ - event.invoke(session,8,changed);worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertNull(reply.get()); - worker.submit(()->{try{Field ready=NativeSession.class.getDeclaredField("signalReady"),identity=NativeSession.class.getDeclaredField("identity");ready.setAccessible(true);identity.setAccessible(true);ready.setBoolean(session,true);identity.set(session,"test");}catch(Exception error){throw new RuntimeException(error);}}).get(5,TimeUnit.SECONDS); - event.invoke(session,8,changed);session.announcementRequest(new JSONObject().put("type","announcements_list").put("request_id","request-test"),"request-test"); - worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertEquals("announcements_changed",reply.get().getString("type"));assertEquals("request-test",failed.get()); - reply.set(null);session.close();event.invoke(session,8,changed);worker.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();assertNull(reply.get()); - }finally{session.close();} - } - @Test public void videoSettingsSendFailureReturnsMatchingRejection()throws Exception{ - var instrumentation=InstrumentationRegistry.getInstrumentation();var reply=new java.util.concurrent.atomic.AtomicReference();var received=new java.util.concurrent.CountDownLatch(1); - NativeSession.Listener listener=new NativeSession.Listener(){ - public void status(String value){}public void connected(){}public void ended(String reason){} - public void data(JSONObject value){reply.set(value);received.countDown();}public void videoSize(int width,int height){}public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){} - }; - NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"123456789","test",listener); - try{ - Field connected=NativeSession.class.getDeclaredField("connected");connected.setAccessible(true);connected.setBoolean(session,true); - // JNI has no peer, so its real send failure must carry the original request to the UI. - session.videoSettings(new RemoteVideoSettings.Values(1,30,2),0xffffffffL); - assertTrue(received.await(5,TimeUnit.SECONDS));assertEquals(12,reply.get().getInt("type"));var settings=reply.get().getJSONObject("video_settings"); - assertEquals(1,settings.getInt("quality"));assertEquals(30,settings.getInt("frame_rate"));assertEquals(2,settings.getInt("preference"));assertEquals(0xffffffffL,settings.getLong("request_id"));assertFalse(settings.getBoolean("accepted")); - }finally{session.close();} - } - @Test public void nativeReportsAndSubmittedFramesReachTheStatisticsCallback()throws Exception{ - var instrumentation=InstrumentationRegistry.getInstrumentation();var sample=new java.util.concurrent.atomic.AtomicReference(); - 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){sample.set(value);} - }; - NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"123456789","test",listener); - try{ - var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true); - event.invoke(session,7,NetworkStatisticsTest.report(1,true,40).toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); - Field worker=NativeSession.class.getDeclaredField("RTC");worker.setAccessible(true);((ExecutorService)worker.get(null)).submit(()->{}).get(5,TimeUnit.SECONDS); - var frame=NativeSession.class.getDeclaredMethod("onNativeVideoFrame",long.class,double.class,int.class,int.class);frame.setAccessible(true);frame.invoke(session,1L,25.0,1920,1080); - Field tick=NativeSession.class.getDeclaredField("statisticsTick");tick.setAccessible(true);instrumentation.runOnMainSync((Runnable)tick.get(session)); - assertNotNull(sample.get());assertEquals("TURN 中继",sample.get().mode());assertEquals(8000000,sample.get().report.traffic[0].inbound);assertTrue(sample.get().report.srtp);assertEquals(40,sample.get().rtt,0);assertEquals(25,sample.get().latency,0);assertEquals(1,sample.get().fps);assertEquals("1920 × 1080",sample.get().resolution()); - }finally{session.close();} - } - @Test public void repeatedNativeConnectedEventsNotifyAndInitializeOnlyOnce()throws Exception{ - var instrumentation=InstrumentationRegistry.getInstrumentation(); - var notifications=new java.util.concurrent.atomic.AtomicInteger(); - NativeSession.Listener listener=new NativeSession.Listener(){ - public void status(String value){}public void connected(){notifications.incrementAndGet();}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){} - }; - NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"123456789","test",listener); - Field workerField=NativeSession.class.getDeclaredField("RTC"),audioField=NativeSession.class.getDeclaredField("audio");workerField.setAccessible(true);audioField.setAccessible(true); - ExecutorService worker=(ExecutorService)workerField.get(null); - var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true); - byte[] connected="{\"status\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8); - try{ - // Exercise the Java native-callback path without starting a network peer. - event.invoke(session,2,connected);Object audio=worker.submit(()->audioField.get(session)).get(5,TimeUnit.SECONDS); - instrumentation.waitForIdleSync();assertEquals(1,notifications.get());assertNotNull(audio); - event.invoke(session,2,connected);event.invoke(session,2,connected); - assertSame("Duplicate readiness must not replace the audio player",audio,worker.submit(()->audioField.get(session)).get(5,TimeUnit.SECONDS)); - instrumentation.waitForIdleSync();assertEquals("One desktop connection must notify the UI only once",1,notifications.get()); - }finally{session.close();worker.submit(()->{}).get(5,TimeUnit.SECONDS);} - } - @Test public void keyboardSupportsDesktopVirtualKeys() { - assertEquals(0x41, KeyMap.windowsCode(KeyEvent.KEYCODE_A)); - assertEquals(0x2E, KeyMap.windowsCode(KeyEvent.KEYCODE_FORWARD_DEL)); - assertEquals(0x11, KeyMap.windowsCode(KeyEvent.KEYCODE_CTRL_LEFT)); - assertEquals(0x5B, KeyMap.windowsCode(KeyEvent.KEYCODE_META_LEFT)); - assertEquals(0xDB | 0x100, KeyMap.ascii('{')); - assertEquals(0x31 | 0x100, KeyMap.ascii('!')); - assertEquals(0, KeyMap.ascii('中')); - } - - @Test public void credentialsRemainEncryptedAndServerScoped() throws Exception { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - SecretStore store = new SecretStore(context); - String scope = "instrumentation.invalid:9099"; - try { - store.put(scope, "test-id@temporary-test-password"); - assertEquals("test-id@temporary-test-password", store.get(scope)); - assertEquals("", store.get("instrumentation.invalid:9100")); - String persisted = context.getSharedPreferences("identities", Context.MODE_PRIVATE).getString(scope, ""); - assertFalse(persisted.contains("temporary-test-password")); - } finally { - context.getSharedPreferences("identities", Context.MODE_PRIVATE).edit().remove(scope).commit(); - } - } - - @Test public void nativePeerCanStartAndCancelRepeatedly() throws Exception { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - Field workerField = NativeSession.class.getDeclaredField("RTC"); workerField.setAccessible(true); - ExecutorService worker = (ExecutorService) workerField.get(null); - Field handleField = NativeSession.class.getDeclaredField("handle"); handleField.setAccessible(true); - NativeSession.Listener listener = new NativeSession.Listener() { - public void status(String value) { } - public void connected() { fail("Loopback port 1 must not establish a session"); } - public void ended(String reason) { } - public void data(JSONObject data) { } - public void videoSize(int width, int height) { } - public void clipboard(String value) { } - public void statistics(RemoteNetworkStatistics.Snapshot value) { } - }; - for (int i = 0; i < 3; i++) { - NativeSession session = new NativeSession(context, "127.0.0.1", 1, "invalid", "test", listener); - session.start(); - assertTrue(worker.submit(() -> handleField.getLong(session)).get(15, TimeUnit.SECONDS) != 0); - session.surface(null); - session.close(); session.close(); - assertEquals(0L, (long) worker.submit(() -> handleField.getLong(session)).get(15, TimeUnit.SECONDS)); - } - } - - @Test public void homeActivityStartsWithoutNetworkConsent() throws Exception { - var instrumentation = InstrumentationRegistry.getInstrumentation(); - Context context = instrumentation.getTargetContext(); - var preferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE); - boolean consent = preferences.getBoolean("networkConsent", false); - preferences.edit().putBoolean("networkConsent", false).commit(); - MainActivity activity = null; - try { - Intent launch = new Intent(context, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - activity = (MainActivity) instrumentation.startActivitySync(launch); - assertNotNull(activity.findViewById(android.R.id.content)); - Field connection = MainActivity.class.getDeclaredField("signaling"); connection.setAccessible(true); - assertNull("The home page must not start a peer without consent", connection.get(activity)); - } finally { - if (activity != null) instrumentation.runOnMainSync(activity::finish); - preferences.edit().putBoolean("networkConsent", consent).commit(); - } - } -} diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/LayoutTest.java b/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/LayoutTest.java deleted file mode 100644 index 96888e4f..00000000 --- a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/LayoutTest.java +++ /dev/null @@ -1,371 +0,0 @@ -package cn.crossdesk.mobile; - -import android.app.Dialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ActivityInfo; -import android.graphics.Bitmap; -import android.graphics.RectF; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.TextView; -import android.widget.Switch; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; -import org.junit.*; -import org.junit.runner.RunWith; -import java.lang.reflect.Field; -import java.util.Map; -import java.util.concurrent.FutureTask; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; -import static org.junit.Assert.*; - -/** Layout and navigation regressions, exercised without a signaling server. */ -@RunWith(AndroidJUnit4.class) -public class LayoutTest { - private final android.app.Instrumentation instrumentation=InstrumentationRegistry.getInstrumentation(); - private MainActivity activity; - private SharedPreferences settings; - private Map saved; - private java.io.File announcementDirectory; - private boolean clearTestPreviews; - private static final String SCOPE="layout-test.invalid:1", ID="123456789"; - @Before public void setup(){ - Context context=instrumentation.getTargetContext();settings=context.getSharedPreferences("settings",Context.MODE_PRIVATE);saved=settings.getAll(); - settings.edit().clear().putBoolean("networkConsent",true).putString("host","layout-test.invalid").putInt("port",1).commit(); - activity=(MainActivity)instrumentation.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));instrumentation.waitForIdleSync(); - } - @After public void cleanup()throws Exception{ - if(activity!=null)main(activity::finish);new RecentConnections(instrumentation.getTargetContext()).delete(SCOPE,ID); - if(clearTestPreviews){RemotePreviewStore store=RemotePreviewStore.get(instrumentation.getTargetContext());store.setEnabled(false);store.io.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();} - if(announcementDirectory!=null)try(var paths=java.nio.file.Files.walk(announcementDirectory.toPath())){for(var path:paths.sorted(java.util.Comparator.reverseOrder()).toList())java.nio.file.Files.delete(path);} - SharedPreferences.Editor editor=settings.edit().clear();for(Map.Entry entry:saved.entrySet()){Object value=entry.getValue();if(value instanceof String)editor.putString(entry.getKey(),(String)value);else if(value instanceof Boolean)editor.putBoolean(entry.getKey(),(Boolean)value);else if(value instanceof Integer)editor.putInt(entry.getKey(),(Integer)value);}editor.commit(); - } - private void main(Runnable action)throws Exception{FutureTask task=new FutureTask<>(()->{action.run();return null;});instrumentation.runOnMainSync(task);task.get();instrumentation.waitForIdleSync();} - private View content(){try{return (View)field("canvas");}catch(Exception error){throw new RuntimeException(error);}} - private View find(View view,String label){ - if(label.contentEquals(view.getContentDescription()==null?"":view.getContentDescription())||(view instanceof TextView&&label.contentEquals(((TextView)view).getText())))return view; - if(view instanceof ViewGroup){ViewGroup group=(ViewGroup)view;for(int i=0;i{View v=find(content(),label);assertNotNull(label,v);assertTrue(label,v.performClick());});} - private Object field(String name)throws Exception{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);} - private void set(String name,Object value)throws Exception{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);f.set(activity,value);} - private void localSignaling()throws Exception{main(()->{try{NativeSession old=(NativeSession)field("signaling");if(old!=null)old.close();set("signaling",new NativeSession(activity,"layout-test.invalid",1,"","",activity));set("signalingServer",SCOPE);}catch(Exception error){throw new RuntimeException(error);}});} - private void refreshHome()throws Exception{localSignaling();main(()->{try{var method=MainActivity.class.getDeclaredMethod("home",String.class);method.setAccessible(true);method.invoke(activity,"");}catch(ReflectiveOperationException error){throw new RuntimeException(error);}});} - private ExecutorService rtc()throws Exception{Field f=NativeSession.class.getDeclaredField("RTC");f.setAccessible(true);return (ExecutorService)f.get(null);} - private void snapshot(String name)throws Exception{ - instrumentation.waitForIdleSync();Thread.sleep(650); // Allow system window/rotation animations to settle before visual review. - PageHost host=(PageHost)field("pageHost");java.util.concurrent.atomic.AtomicBoolean settled=new java.util.concurrent.atomic.AtomicBoolean(); - for(int i=0;i<100;i++){main(()->settled.set(host.getChildCount()==1));if(settled.get())break;Thread.sleep(30);}assertTrue("Page transition must finish",settled.get());Thread.sleep(50); - Bitmap bitmap=instrumentation.getUiAutomation().takeScreenshot();assertNotNull(bitmap); - try(java.io.FileOutputStream output=new java.io.FileOutputStream(new java.io.File(instrumentation.getTargetContext().getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,output);}finally{bitmap.recycle();} - } - @Test public void passwordIsSeparateAndSettingsReturnHome()throws Exception{ - assertNotNull(find(content(),"远程桌面"));assertNotNull(find(content(),"最近连接"));assertNull(find(content(),"访问密码"));snapshot("layout-home"); - main(()->((EditText)find(content(),"对端 ID")).setText(ID));assertEquals("123 456 789",((EditText)field("remote")).getText().toString());click("连接"); - Dialog sheet=(Dialog)field("passwordDialog");assertTrue(sheet.isShowing());View sheetRoot=sheet.getWindow().getDecorView();assertNotNull(find(sheetRoot,"访问密码"));assertNotNull(find(sheetRoot,"保存密码"));snapshot("layout-password");main(sheet::dismiss); - assertEquals(0,new RecentConnections(activity).list(SCOPE).length());click("设置");assertNotNull(find(content(),"鼠标控制"));assertNotNull(find(content(),"隐私与授权,已授权"));snapshot("layout-settings"); - click("绝对位置");assertFalse(settings.getBoolean("relativeMouse",true));click("返回");assertNotNull(find(content(),"远程桌面")); - } - @Test public void aboutMatchesIosNavigationAndReadsCompleteOfflineDocuments()throws Exception{ - click("设置");click("关于");assertNotNull(find(content(),"关于 CrossDesk"));assertNull(find(content(),"使用说明")); - assertNotNull(find(content(),activity.getPackageManager().getPackageInfo(activity.getPackageName(),0).versionName));snapshot("about-home"); - LicenseCatalog catalog=new LicenseCatalog(activity);assertEquals(29,catalog.components.size()); - click("软件许可");assertNotNull(find(content(),"CrossDesk"));assertNotNull(find(content(),"GPL-3.0-only"));snapshot("about-application"); - click("GNU GPL 第 3 版");assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("crossdesk").documents.get(0).asset));snapshot("about-gpl"); - click("返回");click("开源软件权利说明");assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("crossdesk").documents.get(1).asset));click("返回");click("返回"); - click("开源组件");assertNotNull(find(content(),"OpenFEC 告知"));assertNotNull(find(content(),"(c) Copyright 2009 - 2012 INRIA - All rights reserved"));snapshot("about-components"); - click("OpenFEC 许可与源码");assertNotNull(find(content(),"CeCILL-C 1.0 / BSD / CC-BY-SA-3.0"));click("LICENCE_CeCILL-C_V1-en.txt");assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("openfec").documents.get(0).asset));click("返回"); - click("构建配方与项目补丁");click("MiniRTC 构建配方");assertLicenseDocument(LicenseCatalog.read(activity,"about/build/3.txt"));click("返回");click("返回");click("返回"); - click("GLib / GObject / GIO / GModule / GThread / GVDB,LGPL-2.1-or-later and embedded notices");click("版权与许可声明"); - assertLicenseDocument(LicenseCatalog.read(activity,catalog.find("glib").documents.get(8).asset));click("返回");click("返回");click("返回"); - click("源码与构建说明");assertNotNull(find(content(),"本版本源码与构建说明"));snapshot("about-source"); - java.util.concurrent.atomic.AtomicReference opened=new java.util.concurrent.atomic.AtomicReference<>(); - android.app.Instrumentation.ActivityMonitor monitor=new android.app.Instrumentation.ActivityMonitor(){ - @Override public android.app.Instrumentation.ActivityResult onStartActivity(Intent intent){if(Intent.ACTION_VIEW.equals(intent.getAction())){opened.set(intent);return new android.app.Instrumentation.ActivityResult(android.app.Activity.RESULT_OK,null);}return null;} - }; - instrumentation.addMonitor(monitor);try{click("查看源码");assertNotNull(opened.get());assertEquals(catalog.source.getString("sourceURL"),opened.get().getDataString());}finally{instrumentation.removeMonitor(monitor);} - click("构建说明");assertLicenseDocument(LicenseCatalog.read(activity,"about/build/0.txt"));snapshot("about-build");click("返回");click("返回");click("返回");assertNotNull(find(content(),"鼠标控制")); - } - private void assertLicenseDocument(String expected)throws Exception{ - main(()->{ - android.widget.ListView list=(android.widget.ListView)((android.widget.LinearLayout)((ViewGroup)content()).getChildAt(0)).getChildAt(1); - StringBuilder actual=new StringBuilder();for(int i=0;i{ - android.widget.ListView list=(android.widget.ListView)((android.widget.LinearLayout)((ViewGroup)content()).getChildAt(0)).getChildAt(1); - assertEquals(list.getAdapter().getCount()-1,list.getLastVisiblePosition());assertTrue(((TextView)list.getChildAt(0)).isTextSelectable());list.setSelection(0); - }); - } - @Test public void floatingControlsStayWithinPortraitAndLandscape()throws Exception{ - SessionControls overlay=connectedOverlay();click("展开远程控制菜单");assertNotNull(find(content(),"断开连接"));assertFalse(find(content(),"发送文件,暂不支持").isEnabled());snapshot("layout-session-menu"); - click("键盘");assertNotNull(find(content(),"收起键盘"));snapshot("layout-session-keyboard"); - main(()->activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)); - for(int i=0;i<30&&overlay.getWidth()overlay.getHeight());main(overlay::constrainPanels);snapshot("layout-session-landscape"); - main(()->{for(int i=0;i=0);assertTrue(child.getY()>=0);assertTrue(child.getX()+child.getWidth()<=overlay.getWidth()+1);assertTrue(child.getY()+child.getHeight()<=overlay.getHeight()+1);}}); - assertMenuFirstFrame(overlay,()->find(overlay,"展开远程控制菜单").performClick());click("断开连接");assertTrue((Boolean)field("ready")); // Confirmation must not disconnect immediately. - } - private SessionControls connectedOverlay()throws Exception{ - // No peer is started. Only inject a local session object to exercise the connected UI. - main(()->{try{var stop=MainActivity.class.getDeclaredMethod("stopSignaling");stop.setAccessible(true);stop.invoke(activity);}catch(ReflectiveOperationException e){throw new RuntimeException(e);}}); - NativeSession session=new NativeSession(activity,"127.0.0.1",1,ID,"test",activity);set("session",session);set("connectedRemote",ID);main(activity::connected); - return (SessionControls)field("controls"); - } - @Test public void remoteUpgradeNoticeMatchesIosAndKeepsTheSessionConnected()throws Exception{ - connectedOverlay(); - main(()->activity.data(new org.json.JSONObject())); - assertNull(field("remoteUpdateDialog")); - main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject()));}catch(Exception error){throw new RuntimeException(error);}}); - android.app.AlertDialog dialog=(android.app.AlertDialog)field("remoteUpdateDialog");assertNotNull(dialog);assertTrue(dialog.isShowing()); - assertNotNull(find(dialog.getWindow().getDecorView(),"升级提示"));assertNotNull(find(dialog.getWindow().getDecorView(),RemoteVersionCheck.NOTICE)); - assertEquals("知道了",dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).getText().toString());snapshot("remote-upgrade-notice"); - main(()->dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());assertNull(field("remoteUpdateDialog"));assertTrue((Boolean)field("ready")); - main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject()));activity.connected();}catch(Exception error){throw new RuntimeException(error);}}); - assertNull(field("remoteUpdateDialog"));assertTrue((Boolean)field("ready")); - } - @Test public void remoteVersionRequestIsCancelledWhenSessionEnds()throws Exception{ - var reply=new java.util.concurrent.atomic.AtomicReference>();var cancelled=new java.util.concurrent.atomic.AtomicBoolean(); - main(()->{try{set("remoteVersionCheck",new RemoteVersionCheck(callback->{reply.set(callback);return ()->cancelled.set(true);},()->{throw new AssertionError("Stale result must not open a dialog");}));}catch(Exception error){throw new RuntimeException(error);}}); - connectedOverlay(); - main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("app_version","1.0.0")));}catch(Exception error){throw new RuntimeException(error);}}); - assertNotNull(reply.get());main(()->activity.ended(""));assertTrue(cancelled.get()); - main(()->reply.get().accept("{\"version\":\"2.0.0\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8))); - assertNull(field("remoteUpdateDialog"));assertFalse((Boolean)field("ready")); - } - @Test public void remoteVersionCheckRespectsNetworkConsent()throws Exception{ - main(()->{try{set("remoteVersionCheck",new RemoteVersionCheck(callback->{throw new AssertionError("No request without consent");},()->{throw new AssertionError("No notice without consent");}));}catch(Exception error){throw new RuntimeException(error);}}); - settings.edit().putBoolean("networkConsent",false).commit();connectedOverlay(); - main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("app_version","1.0.0")));}catch(Exception error){throw new RuntimeException(error);}}); - assertNull(field("remoteUpdateDialog")); - } - @Test public void floatingMenuIsAnchoredOnItsFirstFrame()throws Exception{ - SessionControls overlay=connectedOverlay(); - assertMenuFirstFrame(overlay,()->find(overlay,"展开远程控制菜单").performClick()); - assertMenuFirstFrame(overlay,()->find(overlay,"网络状态").performClick()); - assertMenuFirstFrame(overlay,()->find(overlay,"返回控制栏").performClick()); - assertMenuFirstFrame(overlay,()->overlay.setMuted(true)); - click("关闭控制栏"); - // Reopen beside a dragged orb at the opposite, bottom edge. - main(()->{View orb=find(overlay,"展开远程控制菜单");orb.setX(24);orb.setY(overlay.getHeight()-orb.getHeight()-24);}); - assertMenuFirstFrame(overlay,()->find(overlay,"展开远程控制菜单").performClick()); - } - @Test public void videoSettingsFollowCapabilitiesAndRemoteAcknowledgements()throws Exception{ - SessionControls overlay=connectedOverlay();click("展开远程控制菜单");click("画面设置"); - assertNotNull(find(content(),"连接建立后可调整,需被控端支持。"));assertFalse(find(content(),"画面质量,高").isEnabled()); - main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("supports_video_settings",true)));}catch(Exception error){throw new RuntimeException(error);}}); - main(((Dialog)field("remoteUpdateDialog"))::dismiss); // This fixture omits the legacy host's version. - RemoteVideoSettings state=(RemoteVideoSettings)field("videoSettings");assertTrue(state.pending);assertTrue(find(content(),"画面质量,中").isSelected());assertTrue(find(content(),"画面采集帧率,30 fps").isSelected());assertTrue(find(content(),"画面偏好,画质优先").isSelected()); - main(()->activity.data(state.selection.status(state.requestId,true)));click("画面质量,低");long first=state.requestId;RemoteVideoSettings.Values accepted=state.selection; - click("画面采集帧率,60 fps");assertTrue(state.pending);assertNull(find(content(),"正在应用…")); - main(()->activity.data(accepted.status(first,true)));assertTrue(find(content(),"画面采集帧率,60 fps").isSelected()); - main(()->activity.data(state.selection.status(state.requestId,false)));assertTrue(find(content(),"画面采集帧率,30 fps").isSelected());assertTrue(find(content(),"画面质量,低").isSelected());assertNotNull(find(content(),"调整失败,请重试。")); - click("画面质量,高");click("画面偏好,平衡");main(()->activity.data(state.selection.status(state.requestId,true)));snapshot("layout-video-settings"); - View panel=menuPanel(overlay);assertMenuFirstFrame(overlay,()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("supports_video_settings",false)));}catch(Exception error){throw new RuntimeException(error);}}); - assertSame(panel,menuPanel(overlay));assertFalse(find(content(),"画面质量,高").isEnabled());assertFalse(state.pending); - } - @Test public void videoSettingsTimeoutRollsBackWithoutMovingMenu()throws Exception{ - SessionControls overlay=connectedOverlay();click("展开远程控制菜单");click("画面设置"); - main(()->{try{activity.data(new org.json.JSONObject().put("host_info",new org.json.JSONObject().put("supports_video_settings",true)));}catch(Exception error){throw new RuntimeException(error);}}); - main(((Dialog)field("remoteUpdateDialog"))::dismiss); - RemoteVideoSettings state=(RemoteVideoSettings)field("videoSettings");main(()->activity.data(state.selection.status(state.requestId,true))); - click("画面质量,低");RectF before=bounds(menuPanel(overlay));Thread.sleep(5300);instrumentation.waitForIdleSync(); - assertNotNull(find(content(),"调整失败,请重试。"));assertTrue(find(content(),"画面质量,中").isSelected());assertEquals(before,bounds(menuPanel(overlay))); - } - @Test public void networkStatisticsShowIosRowsAndRefreshWithoutRebuilding()throws Exception{ - SessionControls overlay=connectedOverlay();RemoteNetworkStatistics stats=new RemoteNetworkStatistics(); - stats.receive(new RemoteNetworkStatistics.Report(NetworkStatisticsTest.report(1,true,40)),10); - for(int i=0;i<60;i++)stats.frame(i+1,25,1920,1080,10+i/60.0); - main(()->activity.statistics(stats.snapshot(10.99)));click("展开远程控制菜单");click("网络状态"); - // Keep the indicator visible in both screenshots so text overlap can be reviewed. - View ancestor=find(content(),"媒体加密");while(!(ancestor instanceof android.widget.ScrollView))ancestor=(View)ancestor.getParent(); - android.widget.ScrollView scroll=(android.widget.ScrollView)ancestor;main(()->scroll.setScrollbarFadingEnabled(false)); - assertNotNull(find(content(),"视频,接收 8.0 Mbps,发送 0 bps,丢包率 2.0%"));assertNotNull(find(content(),"音频,接收 64 Kbps,发送 0 bps,丢包率 1.0%"));assertNotNull(find(content(),"数据,接收 2 Kbps,发送 3 Kbps,丢包率 3.0%"));assertNotNull(find(content(),"合计,接收 8.1 Mbps,发送 3 Kbps,丢包率 6.0%"));assertNotNull(find(content(),"60 FPS"));snapshot("layout-network-statistics"); - main(()->scroll.fullScroll(View.FOCUS_DOWN)); - assertNotNull(find(content(),"1920 × 1080"));assertNotNull(find(content(),"25 ms"));assertNotNull(find(content(),"40 ms"));assertNotNull(find(content(),"TURN 中继"));assertNotNull(find(content(),"SRTP 已启用"));snapshot("layout-network-details"); - View panel=menuPanel(overlay);RectF position=bounds(panel);stats.receive(new RemoteNetworkStatistics.Report(NetworkStatisticsTest.report(0,false,-1)),12); - main(()->activity.statistics(stats.snapshot(12)));assertSame(panel,menuPanel(overlay));assertEquals(position,bounds(panel));assertNotNull(find(content(),"0 FPS"));assertNotNull(find(content(),"P2P 直连"));assertNotNull(find(content(),"未启用")); - main(()->activity.statistics(stats.snapshot(15)));assertNotNull(find(content(),"合计,接收 —,发送 —,丢包率 —"));assertNull(find(content(),"P2P 直连")); - } - @Test public void homeVideoPreferenceSeedsNextConnection()throws Exception{ - click("设置");click("帧率优先");assertEquals(0,settings.getInt("videoPreference",1));assertNotNull(find(content(),RemoteVideoSettings.DETAILS[0]));click("返回");refreshHome(); - main(()->((EditText)find(content(),"对端 ID")).setText(ID));click("连接");Dialog sheet=(Dialog)field("passwordDialog");View sheetRoot=sheet.getWindow().getDecorView(); - main(()->{((EditText)find(sheetRoot,"访问密码")).setText("preference-test");find(sheetRoot,"确认连接").performClick();});main(activity::connected); - RemoteVideoSettings state=(RemoteVideoSettings)field("videoSettings");assertEquals(0,state.selection.preference); - } - private View menuPanel(SessionControls overlay){ - View panel=find(overlay,"关闭控制栏");assertNotNull(panel); - while(panel.getParent()!=overlay)panel=(View)panel.getParent();return panel; - } - private RectF bounds(View view){return new RectF(view.getX(),view.getY(),view.getX()+view.getWidth(),view.getY()+view.getHeight());} - private void assertMenuFirstFrame(SessionControls overlay,Runnable open)throws Exception{ - RectF first=new RectF(); - main(()->{ - open.run(); - // A frame may be laid out and drawn before the message queue runs any posted positioning. - overlay.measure(View.MeasureSpec.makeMeasureSpec(overlay.getWidth(),View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(overlay.getHeight(),View.MeasureSpec.EXACTLY)); - overlay.layout(overlay.getLeft(),overlay.getTop(),overlay.getRight(),overlay.getBottom()); - View panel=menuPanel(overlay),orb=find(overlay,"收起远程控制菜单");first.set(bounds(panel)); - float density=activity.getResources().getDisplayMetrics().density,margin=Math.round(8*density),gap=Math.round(12*density); - float x=orb.getX()+orb.getWidth()/2f>overlay.getWidth()/2f?orb.getX()-gap-panel.getWidth():orb.getX()+orb.getWidth()+gap; - assertEquals("First frame must already be anchored horizontally",Math.max(margin,Math.min(x,overlay.getWidth()-panel.getWidth()-margin)),first.left,.5f); - assertEquals("First frame must already be anchored vertically",Math.max(margin,Math.min(orb.getY(),overlay.getHeight()-panel.getHeight()-margin)),first.top,.5f); - assertTrue(first.right<=overlay.getWidth()-margin+.5f);assertTrue(first.bottom<=overlay.getHeight()-margin+.5f); - }); - main(()->assertEquals("Posted work must not move the menu after its first frame",first,bounds(menuPanel(overlay)))); - } - @Test public void savedPasswordsNeverEnterHistoryAndDeleteWithRecord()throws Exception{ - RecentConnections history=new RecentConnections(activity); - try{ - history.save(SCOPE,ID,"测试电脑","windows",true,"temporary-layout-test"); - assertEquals("temporary-layout-test",history.password(SCOPE,ID));assertEquals("",history.password("different.invalid:1",ID)); - assertFalse(history.list(SCOPE).toString().contains("temporary-layout-test"));history.delete(SCOPE,ID);assertEquals("",history.password(SCOPE,ID));assertEquals(0,history.list(SCOPE).length()); - }finally{history.delete(SCOPE,ID);} - } - @Test public void savedPasswordConnectsFromRecentAndAfterRestart()throws Exception{ - RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test");refreshHome(); - click("连接 密码测试电脑");assertSavedAttempt("saved-flow-test"); - main(activity::finish); - activity=(MainActivity)instrumentation.startActivitySync(new Intent(instrumentation.getTargetContext(),MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - refreshHome(); - main(()->((EditText)find(content(),"对端 ID")).setText(ID));click("连接");assertSavedAttempt("saved-flow-test"); - Dialog progress=(Dialog)field("progressDialog");main(()->find(progress.getWindow().getDecorView(),"取消连接").performClick()); - assertEquals("Cancellation must retain saved credentials","saved-flow-test",history.password(SCOPE,ID)); - } - @Test public void savedPasswordSurvivesRepeatedConnectedCallbacks()throws Exception{ - refreshHome();main(()->((EditText)find(content(),"对端 ID")).setText(ID));click("连接"); - Dialog sheet=(Dialog)field("passwordDialog");View sheetRoot=sheet.getWindow().getDecorView(); - main(()->{((EditText)find(sheetRoot,"访问密码")).setText("duplicate-callback-test");rememberSwitch(sheetRoot).setChecked(true);find(sheetRoot,"确认连接").performClick();}); - RecentConnections history=new RecentConnections(activity); - main(activity::connected);assertEquals("duplicate-callback-test",history.password(SCOPE,ID)); - Object controls=field("controls"); - main(activity::connected); - assertEquals("Repeated success must not overwrite the saved password with the cleared pending value","duplicate-callback-test",history.password(SCOPE,ID)); - assertSame("Repeated success must not rebuild the active session UI",controls,field("controls")); - main(()->activity.ended(""));refreshHome();click("连接 "+ID);assertSavedAttempt("duplicate-callback-test"); - } - @Test public void emptyPasswordCannotOverwriteSavedCredential()throws Exception{ - RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test"); - assertThrows(IllegalArgumentException.class,()->history.save(SCOPE,ID,"密码测试电脑","windows",true,"")); - assertEquals("saved-flow-test",history.password(SCOPE,ID)); - } - @Test public void legacyEmptyPasswordKeepsRememberChoiceWhenRequestingRepair()throws Exception{ - new RecentConnections(activity).save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test"); - new SecretStore(activity).put("remote-password:"+SCOPE+":"+ID,""); // Reproduce the old duplicate-callback overwrite. - refreshHome();click("连接 密码测试电脑"); - Dialog sheet=(Dialog)field("passwordDialog");assertNotNull(sheet);View sheetRoot=sheet.getWindow().getDecorView(); - assertEquals("",((EditText)find(sheetRoot,"访问密码")).getText().toString()); - assertNotNull("Explain why the previously remembered credential needs repair",((EditText)find(sheetRoot,"访问密码")).getError()); - assertTrue("Preserve the user's existing choice to remember this device",rememberSwitch(sheetRoot).isChecked());assertNull(field("session")); - } - private void assertSavedAttempt(String secret)throws Exception{ - assertNull("A saved password must bypass the password sheet",field("passwordDialog")); - assertTrue(((Dialog)field("progressDialog")).isShowing());assertEquals(true,field("rememberPassword")); - NativeSession session=(NativeSession)field("session");assertNotNull(session); - rtc().submit(()->{try{Field remote=NativeSession.class.getDeclaredField("remote"),password=NativeSession.class.getDeclaredField("password");remote.setAccessible(true);password.setAccessible(true);assertEquals(ID,remote.get(session));assertEquals("The connection must receive the decrypted credential",secret,password.get(session));}catch(ReflectiveOperationException error){throw new RuntimeException(error);}}).get(5,TimeUnit.SECONDS); - } - @Test public void unreadableSavedPasswordFallsBackToInput()throws Exception{ - new RecentConnections(activity).save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test"); - // Damage this test credential only; never alter the device's Keystore key. - activity.getSharedPreferences("identities",Context.MODE_PRIVATE).edit().putString("remote-password:"+SCOPE+":"+ID,"invalid-test-record").commit(); - refreshHome();click("连接 密码测试电脑"); - Dialog sheet=(Dialog)field("passwordDialog");assertNotNull(sheet);assertTrue(sheet.isShowing()); - assertEquals("",((EditText)find(sheet.getWindow().getDecorView(),"访问密码")).getText().toString());assertNull(field("session")); - } - @Test public void rejectedSavedPasswordCanBeReplacedAfterSuccessfulRetry()throws Exception{ - RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"密码测试电脑","windows",true,"saved-flow-test");refreshHome(); - click("连接 密码测试电脑");assertSavedAttempt("saved-flow-test"); - NativeSession session=(NativeSession)field("session");var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true); - event.invoke(session,2,"{\"status\":6}".getBytes(java.nio.charset.StandardCharsets.UTF_8));rtc().submit(()->{}).get(5,TimeUnit.SECONDS);main(()->{}); - Dialog sheet=(Dialog)field("passwordDialog");assertNotNull("A rejected saved password must offer correction",sheet); - View sheetRoot=sheet.getWindow().getDecorView();EditText input=(EditText)find(sheetRoot,"访问密码"); - assertEquals("",input.getText().toString());assertNotNull(input.getError());assertTrue(rememberSwitch(sheetRoot).isChecked());assertNull(field("session")); - localSignaling(); - main(()->{input.setText("replacement-flow-test");find(sheetRoot,"确认连接").performClick();}); - assertEquals("Failed/unconfirmed attempts must not replace stored credentials","saved-flow-test",history.password(SCOPE,ID)); - main(activity::connected);assertEquals("replacement-flow-test",history.password(SCOPE,ID)); - } - @Test public void announcementsSupportBadgeDetailRefreshSwipeDeleteAndEmptyState()throws Exception{ - refreshHome();java.util.ArrayDeque requests=new java.util.ArrayDeque<>(); - announcementDirectory=java.nio.file.Files.createTempDirectory(activity.getCacheDir().toPath(),"announcement-layout-").toFile(); - main(()->{try{ - ((AnnouncementInbox)field("announcementInbox")).close();AnnouncementInbox inbox=new AnnouncementInbox(announcementDirectory); - inbox.sender=(message,id)->requests.add(message);inbox.changed=()->{try{var render=MainActivity.class.getDeclaredMethod("renderAnnouncements");render.setAccessible(true);render.invoke(activity);}catch(Exception e){throw new RuntimeException(e);}}; - set("announcementInbox",inbox);inbox.configure("layout-test.invalid",1,ID);inbox.setConnected(true);drainAnnouncements(inbox,requests,3); - }catch(Exception error){throw new RuntimeException(error);}}); - AnnouncementInbox inbox=(AnnouncementInbox)field("announcementInbox");assertEquals("3",((TextView)field("announcementBadge")).getText().toString());snapshot("layout-announcement-badge"); - click("通知公告");main(()->drainAnnouncements(inbox,requests,3));assertNotNull(find(content(),"共 3 条 · 3 条未读"));snapshot("layout-announcements"); - main(()->{View title=find(content(),"欢迎使用 CrossDesk");while(!title.isClickable())title=(View)title.getParent();title.performClick();}); - assertNotNull(find(content(),"公告详情"));assertEquals(2,inbox.unread);snapshot("layout-announcement-detail"); - click("返回");main(()->drainAnnouncements(inbox,requests,3));assertNotNull(find(content(),"共 3 条 · 2 条未读")); - main(()->{try{AnnouncementView scroll=(AnnouncementView)field("announcementView");gesture(scroll,scroll.getWidth()/2f,30,scroll.getWidth()/2f,scroll.getHeight()/2f);}catch(Exception e){throw new RuntimeException(e);}}); - assertFalse("Pull to refresh must request the catalog",requests.isEmpty());main(()->drainAnnouncements(inbox,requests,3)); - main(()->{View title=find(content(),"欢迎使用 CrossDesk");while(!title.isClickable())title=(View)title.getParent();View swipe=(View)title.getParent();gesture(swipe,swipe.getWidth()-30,swipe.getHeight()/2f,30,swipe.getHeight()/2f);});snapshot("layout-announcement-swipe"); - main(()->{View delete=find(content(),"删除");assertNotNull(delete);delete.performClick();}); - var dialogField=AnnouncementView.class.getDeclaredField("deleteDialog");dialogField.setAccessible(true);android.app.AlertDialog dialog=(android.app.AlertDialog)dialogField.get(field("announcementView"));assertTrue(dialog.isShowing()); - main(()->dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());assertEquals(2,inbox.total);assertEquals(2,inbox.unread);assertNull(find(content(),"欢迎使用 CrossDesk")); - click("刷新公告");main(()->drainAnnouncements(inbox,requests,0));assertNotNull(find(content(),"暂无公告"));snapshot("layout-announcement-empty"); - click("刷新公告");main(()->inbox.failed(requests.remove().optString("request_id")));assertNotNull(find(content(),"公告加载失败,请重试。"));click("重试");main(()->drainAnnouncements(inbox,requests,0));assertFalse(inbox.failed); - click("返回");assertEquals(View.GONE,((View)field("announcementBadge")).getVisibility()); - } - private void drainAnnouncements(AnnouncementInbox inbox,java.util.ArrayDeque requests,int count){ - try{int limit=10;while(!requests.isEmpty()){assertTrue(limit-->0);inbox.receive(AnnouncementInboxTest.answer(requests.remove(),count,count==0?2:1));}}catch(Exception error){throw new RuntimeException(error);} - } - private void gesture(View view,float fromX,float fromY,float toX,float toY){ - long now=android.os.SystemClock.uptimeMillis(); - for(int i=0;i<4;i++){int action=i==0?android.view.MotionEvent.ACTION_DOWN:i==3?android.view.MotionEvent.ACTION_UP:android.view.MotionEvent.ACTION_MOVE; - android.view.MotionEvent event=android.view.MotionEvent.obtain(now,now+i*50,action,fromX+(toX-fromX)*i/3,fromY+(toY-fromY)*i/3,0);view.dispatchTouchEvent(event);event.recycle();} - } - @Test public void previewOptionRequiresConfirmationAndClearingPreservesHistoryAndPassword()throws Exception{ - clearTestPreviews=true;refreshHome();awaitPreviews();RemotePreviewStore store=RemotePreviewStore.get(activity);assertFalse(store.enabled());click("设置"); - togglePreviews();assertFalse(store.enabled());main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_NEGATIVE).performClick());assertFalse(store.enabled()); - togglePreviews();main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());awaitPreviews();assertTrue(store.enabled());assertNull(field("previewCapture")); - main(()->find(content(),"保存远程画面预览").requestRectangleOnScreen(new android.graphics.Rect(0,0,100,400),true));snapshot("layout-preview-settings"); - RecentConnections history=new RecentConnections(activity);history.save(SCOPE,ID,"预览测试电脑","windows",true,"preview-test-password"); - store.save(store.begin(SCOPE,ID),RemotePreviewStoreTest.solid(android.graphics.Color.GREEN));awaitPreviews();click("返回");awaitPreviews();assertPreviewCard();snapshot("layout-preview-card"); - click("设置");click("清除预览图");main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());awaitPreviews(); - assertTrue(store.enabled());assertEquals("preview-test-password",history.password(SCOPE,ID));assertTrue(history.contains(SCOPE,ID));assertFalse(store.directory.exists()); - store.save(store.begin(SCOPE,ID),RemotePreviewStoreTest.solid(android.graphics.Color.BLUE));awaitPreviews();togglePreviews();awaitPreviews();assertFalse(store.enabled());assertFalse(store.directory.exists()); - togglePreviews();main(()->previewDialogUnchecked().getButton(android.content.DialogInterface.BUTTON_POSITIVE).performClick());awaitPreviews(); - store.save(store.begin(SCOPE,ID),RemotePreviewStoreTest.solid(android.graphics.Color.RED));awaitPreviews();click("隐私与授权,已授权");click("撤回联网授权"); - var nodes=instrumentation.getUiAutomation().getRootInActiveWindow().findAccessibilityNodeInfosByText("撤回");boolean confirmed=false; - for(var node:nodes)if("撤回".contentEquals(node.getText()==null?"":node.getText())&&node.isClickable()){confirmed=node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK);break;} - assertTrue(confirmed);instrumentation.waitForIdleSync();awaitPreviews();assertFalse(store.enabled());assertFalse(settings.getBoolean(RemotePreviewStore.OPTION,true));assertFalse(store.directory.exists());assertTrue(history.contains(SCOPE,ID));assertEquals("preview-test-password",history.password(SCOPE,ID)); - } - @Test public void previewCopiesOnlyVideoOncePerConnectionAndSurvivesRestart()throws Exception{ - clearTestPreviews=true;refreshHome();awaitPreviews();RemotePreviewStore store=RemotePreviewStore.get(activity);store.setEnabled(true);awaitPreviews(); - main(()->{try{var begin=MainActivity.class.getDeclaredMethod("begin",String.class,String.class);begin.setAccessible(true);begin.invoke(activity,ID,"");}catch(Exception error){throw new RuntimeException(error);}});main(activity::connected); - RemoteVideoView video=(RemoteVideoView)field("video");RemotePreviewStore.Capture capture=(RemotePreviewStore.Capture)field("previewCapture");assertNotNull(capture); - var surfaceField=RemoteVideoView.class.getDeclaredField("video");surfaceField.setAccessible(true);android.view.SurfaceView surface=(android.view.SurfaceView)surfaceField.get(video); - main(()->{video.videoSize(800,600);surface.getHolder().setFixedSize(800,600);}); - for(int i=0;i<30&&surface.getHolder().getSurfaceFrame().height()!=600;i++){Thread.sleep(50);instrumentation.waitForIdleSync();} - assertEquals(600,surface.getHolder().getSurfaceFrame().height()); - main(()->{ - android.graphics.Canvas canvas=surface.getHolder().lockCanvas();assertNotNull(canvas);android.graphics.Paint paint=new android.graphics.Paint();canvas.drawColor(android.graphics.Color.YELLOW); - paint.setColor(android.graphics.Color.GREEN);canvas.drawRect(0,75,800,525,paint);paint.setColor(android.graphics.Color.RED);canvas.drawRect(0,75,200,525,paint);paint.setColor(android.graphics.Color.BLUE);canvas.drawRect(600,75,800,525,paint);surface.getHolder().unlockCanvasAndPost(canvas); - View overlay=new View(activity);overlay.setBackgroundColor(android.graphics.Color.MAGENTA);video.addView(overlay,new android.widget.FrameLayout.LayoutParams(120,120,android.view.Gravity.CENTER)); - }); - RemoteNetworkStatistics.Snapshot sample=new RemoteNetworkStatistics.Snapshot(null,1,800,600,10,3);main(()->activity.statistics(sample)); - java.io.File file=new java.io.File(store.directory,capture.key+".jpg");for(int i=0;i<60&&!file.exists();i++){Thread.sleep(50);instrumentation.waitForIdleSync();}awaitPreviews();assertTrue("Rendered frame should be saved",file.isFile()); - Bitmap image=android.graphics.BitmapFactory.decodeFile(file.getAbsolutePath());assertEquals(640,image.getWidth());assertEquals(360,image.getHeight()); - assertTrue(android.graphics.Color.green(image.getPixel(320,180))>240);assertTrue(android.graphics.Color.red(image.getPixel(320,180))<20);assertTrue(android.graphics.Color.red(image.getPixel(320,10))<20);assertTrue(android.graphics.Color.red(image.getPixel(10,180))>240);assertTrue(android.graphics.Color.blue(image.getPixel(630,180))>240);image.recycle(); - byte[] first=java.nio.file.Files.readAllBytes(file.toPath());main(()->{android.graphics.Canvas canvas=surface.getHolder().lockCanvas();canvas.drawColor(android.graphics.Color.BLUE);surface.getHolder().unlockCanvasAndPost(canvas);activity.statistics(sample);});awaitPreviews();assertArrayEquals(first,java.nio.file.Files.readAllBytes(file.toPath())); - main(()->activity.ended(""));awaitPreviews();assertPreviewCard();snapshot("layout-preview-captured"); - main(activity::finish);activity=(MainActivity)instrumentation.startActivitySync(new Intent(instrumentation.getTargetContext(),MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));awaitPreviews();assertPreviewCard(); - new RecentConnections(activity).delete(SCOPE,ID);awaitPreviews();assertFalse("Deleting history also deletes its preview",file.exists()); - } - private void togglePreviews()throws Exception{main(()->{try{Switch toggle=(Switch)field("previewSwitch");assertTrue(toggle.isEnabled());toggle.performClick();}catch(Exception error){throw new RuntimeException(error);}});} - private android.app.AlertDialog previewDialog()throws Exception{return (android.app.AlertDialog)field("previewDialog");} - private android.app.AlertDialog previewDialogUnchecked(){try{return previewDialog();}catch(Exception error){throw new RuntimeException(error);}} - private void awaitPreviews()throws Exception{RemotePreviewStore store=RemotePreviewStore.get(activity);for(int i=0;i<2;i++){store.io.submit(()->{}).get(5,TimeUnit.SECONDS);instrumentation.waitForIdleSync();}} - private void assertPreviewCard(){android.widget.ImageView image=findPreviewImage(content());assertNotNull("Preview card image",image);assertEquals(View.VISIBLE,image.getVisibility());assertNotNull(image.getDrawable());} - private android.widget.ImageView findPreviewImage(View view){if(view instanceof android.widget.ImageView)return (android.widget.ImageView)view;if(view instanceof ViewGroup){ViewGroup group=(ViewGroup)view;for(int i=0;i saved; - private PageHost host; - - @Before public void setup()throws Exception{ - Context context=instrumentation.getTargetContext();settings=context.getSharedPreferences("settings",Context.MODE_PRIVATE);saved=settings.getAll(); - settings.edit().putBoolean("networkConsent",true).putString("host","navigation-test.invalid").putInt("port",1).commit(); - activity=(MainActivity)instrumentation.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));instrumentation.waitForIdleSync();host=(PageHost)field(activity,"pageHost"); - } - @After public void cleanup()throws Exception{ - if(activity!=null)main(activity::finish); - SharedPreferences.Editor editor=settings.edit().clear();for(var entry:saved.entrySet()){ - Object v=entry.getValue();if(v instanceof String)editor.putString(entry.getKey(),(String)v);else if(v instanceof Boolean)editor.putBoolean(entry.getKey(),(Boolean)v);else if(v instanceof Integer)editor.putInt(entry.getKey(),(Integer)v); - }editor.commit(); - } - private void main(Runnable action)throws Exception{FutureTask task=new FutureTask<>(action,null);instrumentation.runOnMainSync(task);task.get();instrumentation.waitForIdleSync();} - private static Object field(Object object,String name){try{var f=object.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(object);}catch(Exception error){throw new RuntimeException(error);}} - private void set(String name,Object value){try{var f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);f.set(activity,value);}catch(Exception error){throw new RuntimeException(error);}} - private void invoke(String name){try{var method=MainActivity.class.getDeclaredMethod(name);method.setAccessible(true);method.invoke(activity);}catch(Exception error){throw new RuntimeException(error);}} - private FrameLayout page(){return (FrameLayout)field(activity,"canvas");} - private View find(View root,String label){ - if(label.contentEquals(root.getContentDescription()==null?"":root.getContentDescription())||(root instanceof TextView&&label.contentEquals(((TextView)root).getText())))return root; - if(root instanceof ViewGroup){var group=(ViewGroup)root;for(int i=0;i positioned=new FutureTask<>(()->{assertNotNull("Transition must start",animator());animator().pause();animator().setCurrentPlayTime(time);return null;}); - main(()->{ - navigation.run();host.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ - @Override public boolean onPreDraw(){host.getViewTreeObserver().removeOnPreDrawListener(this);positioned.run();return true;} - }); - });positioned.get(5,TimeUnit.SECONDS); - } - private void finishTransition()throws Exception{main(()->{assertNotNull(animator());animator().end();assertSettled();});} - private void assertSettled(){ - assertEquals(1,host.getChildCount());assertSame(page(),host.getChildAt(0));assertNull(animator()); - assertEquals(0,page().getTranslationX(),.01f);assertEquals(1,page().getAlpha(),.01f);assertEquals(0,page().getElevation(),.01f);assertEquals(View.LAYER_TYPE_NONE,page().getLayerType()); - assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO,page().getImportantForAccessibility());assertNotEquals(ViewGroup.FOCUS_BLOCK_DESCENDANTS,page().getDescendantFocusability()); - } - private void snapshot(String name)throws Exception{ - Bitmap image=instrumentation.getUiAutomation().takeScreenshot();assertNotNull(image); - try(var output=new java.io.FileOutputStream(new java.io.File(activity.getExternalFilesDir(null),name+".png"))){image.compress(Bitmap.CompressFormat.PNG,100,output);}finally{image.recycle();} - } - - @Test public void pushAndPopHaveOppositeMotionAndCleanUp()throws Exception{ - main(this::assertSettled);FrameLayout home=page(); - transition(()->{click("设置");assertEquals(host.getWidth(),page().getTranslationX(),.01f);assertEquals(0,home.getTranslationX(),.01f);},140); - main(()->{assertEquals(350,animator().getDuration());assertEquals(2,host.getChildCount());assertSame(page(),host.getChildAt(1));assertTrue(page().getTranslationX()>0);assertTrue(home.getTranslationX()<0);}); - snapshot("navigation-push-middle");finishTransition();assertNull(home.getParent());FrameLayout settingsPage=page(); - transition(()->click("返回"),140); - main(()->{assertSame(page(),host.getChildAt(0));assertTrue(page().getTranslationX()<0);assertTrue(settingsPage.getTranslationX()>0);}); - snapshot("navigation-pop-middle");finishTransition();assertNull(settingsPage.getParent());assertNotNull(find(page(),"远程桌面")); - } - - @Test public void interruptedNavigationUsesNewestPageAndBackDirection()throws Exception{ - FrameLayout home=page();transition(()->click("设置"),90);FrameLayout settingsPage=page(); - transition(()->click("关于"),90);FrameLayout about=page(); - main(()->{assertNull(home.getParent());assertEquals(2,host.getChildCount());assertSame(settingsPage,host.getChildAt(0));assertTrue(about.getTranslationX()>0);}); - transition(()->invoke("back"),90); - main(()->{assertNull(settingsPage.getParent());assertTrue(page().getTranslationX()<0);assertTrue(about.getTranslationX()>0);});finishTransition(); - transition(()->invoke("back"),90);finishTransition();assertNotNull(find(page(),"远程桌面")); - } - - @Test public void normalPlaybackFinishesAndRestoresInteractions()throws Exception{ - transition(()->click("设置"),0);main(()->animator().resume()); - for(int i=0;i<100;i++){ - java.util.concurrent.atomic.AtomicBoolean done=new java.util.concurrent.atomic.AtomicBoolean();main(()->done.set(host.getChildCount()==1)); - if(done.get())break;Thread.sleep(30); - } - main(this::assertSettled);assertNotNull(find(page(),"鼠标控制"));snapshot("navigation-settings-finished"); - } - - @Test public void refreshAndBackgroundFinishWithoutExtraPages()throws Exception{ - transition(()->click("设置"),90);FrameLayout old=page(); - main(()->{invoke("settings");assertSettled();assertNull(old.getParent());}); - transition(()->click("关于"),90); - main(()->{instrumentation.callActivityOnStop(activity);assertSettled();assertEquals("about",field(activity,"page"));}); - main(()->instrumentation.callActivityOnStart(activity)); - } - - @Test public void sessionFadeKeepsSurfaceOpaqueAndUnlayered()throws Exception{ - FrameLayout home=page(); - transition(()->{set("session",new NativeSession(activity,"navigation-test.invalid",1,"","",activity));invoke("sessionScreen");},100); - FrameLayout session=page(); - main(()->{assertEquals(200,animator().getDuration());assertSame(session,host.getChildAt(0));assertEquals(1,session.getAlpha(),.01f);assertEquals(View.LAYER_TYPE_NONE,session.getLayerType());assertEquals(0,session.getTranslationX(),.01f);assertEquals(.5f,home.getAlpha(),.02f);});finishTransition(); - transition(()->activity.ended(""),100); - main(()->{assertEquals(200,animator().getDuration());assertSame(session,host.getChildAt(0));assertEquals(1,session.getAlpha(),.01f);assertEquals(View.LAYER_TYPE_NONE,session.getLayerType());assertEquals(.5f,page().getAlpha(),.02f);});finishTransition();assertNull(session.getParent()); - } - - @Test public void touchIsBlockedDuringMotionAndResizeFinishesIt()throws Exception{ - transition(()->click("设置"),90); - main(()->{ - MotionEvent event=MotionEvent.obtain(0,0,MotionEvent.ACTION_DOWN,40,100,0); - try{assertTrue(host.onInterceptTouchEvent(event));assertTrue(host.onTouchEvent(event));}finally{event.recycle();} - host.layout(0,0,host.getWidth()+1,host.getHeight());assertSettled(); - event=MotionEvent.obtain(0,0,MotionEvent.ACTION_DOWN,40,100,0);try{assertFalse(host.onInterceptTouchEvent(event));}finally{event.recycle();} - }); - } - - @Test public void disabledSystemAnimationsSwapImmediately()throws Exception{ - String scale=shell("settings get global animator_duration_scale").trim(); - try{ - shell("settings put global animator_duration_scale 0");awaitAnimations(false); - main(()->{click("设置");assertSettled();click("返回");assertSettled();}); - }finally{ - // Deleting an unset value alone leaves WindowManager's cached scale at zero. - shell("settings put global animator_duration_scale "+(scale.equals("null")?"1":scale));awaitAnimations(scale.equals("null")||Float.parseFloat(scale)>0); - if(scale.equals("null"))shell("settings delete global animator_duration_scale"); - } - } - private String shell(String command)throws Exception{try(var input=new android.os.ParcelFileDescriptor.AutoCloseInputStream(instrumentation.getUiAutomation().executeShellCommand(command))){return new String(input.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8);}} - private void awaitAnimations(boolean enabled)throws Exception{ - for(int i=0;i<100;i++){if(ValueAnimator.areAnimatorsEnabled()==enabled)return;Thread.sleep(30);}assertEquals(enabled,ValueAnimator.areAnimatorsEnabled()); - } -} diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/RemotePreviewStoreTest.java b/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/RemotePreviewStoreTest.java deleted file mode 100644 index e77d3f61..00000000 --- a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/RemotePreviewStoreTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package cn.crossdesk.mobile; - -import android.content.*; -import android.graphics.*; -import androidx.test.platform.app.InstrumentationRegistry; -import org.junit.*; -import java.io.*; -import java.nio.file.Files; -import java.util.*; -import java.util.concurrent.*; -import static org.junit.Assert.*; - -public class RemotePreviewStoreTest { - private Context context; - private File root; - private SharedPreferences options,index; - private String prefix; - private RemotePreviewStore store; - @Before public void setup()throws Exception{ - context=InstrumentationRegistry.getInstrumentation().getTargetContext();prefix="preview-test-"+UUID.randomUUID(); - options=context.getSharedPreferences(prefix,0);index=context.getSharedPreferences(prefix+"-index",0); - root=Files.createTempDirectory(context.getCacheDir().toPath(),"previews-").toFile(); - store=new RemotePreviewStore(new File(root,"images"),options,index); - } - @After public void cleanup()throws Exception{ - store.directory.setWritable(true,false);store.clear();flush();store.io.shutdown(); - try(var paths=Files.walk(root.toPath())){for(var path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);} - context.deleteSharedPreferences(prefix);context.deleteSharedPreferences(prefix+"-index"); - } - private void flush()throws Exception{store.io.submit(()->{}).get(5,TimeUnit.SECONDS);InstrumentationRegistry.getInstrumentation().waitForIdleSync();} - private void enable(){options.edit().putBoolean("networkConsent",true).commit();store.setEnabled(true);} - static Bitmap solid(int color){Bitmap image=Bitmap.createBitmap(640,360,Bitmap.Config.ARGB_8888);image.eraseColor(color);return image;} - private Bitmap load(String server,String id)throws Exception{ - var result=new java.util.concurrent.atomic.AtomicReference();var done=new CountDownLatch(1); - store.load(server,id,image->{result.set(image);done.countDown();});assertTrue(done.await(5,TimeUnit.SECONDS));return result.get(); - } - @Test public void offByDefaultAndConsentCannotBeBypassed()throws Exception{ - assertFalse(store.enabled());assertNull(store.begin("server","1"));store.setEnabled(true);flush();assertFalse(store.enabled()); - options.edit().putBoolean("networkConsent",true).commit();assertFalse(store.enabled()); - enable();assertNotNull(store.begin("server","1")); - options.edit().putBoolean("networkConsent",false).commit();store.enforceConsent();flush(); - assertFalse(options.getBoolean(RemotePreviewStore.OPTION,true));assertNull(store.begin("server","1")); - } - @Test public void jpegPersistsAcrossStoreRecreationAndRemainsServerScoped()throws Exception{ - enable();var capture=store.begin("server:1","123456789");store.save(capture,solid(Color.GREEN));flush(); - assertTrue(new File(store.directory,capture.key+".jpg").isFile()); - assertNull(load("server:2","123456789"));assertNull(load("server:1","other")); - store.io.shutdown();store=new RemotePreviewStore(store.directory,options,index); - Bitmap loaded=load("server:1","123456789");assertNotNull(loaded);assertEquals(640,loaded.getWidth());assertEquals(360,loaded.getHeight());assertTrue(Color.green(loaded.getPixel(320,180))>240);loaded.recycle(); - } - @Test public void clearRejectsQueuedAndLateCapturesWithoutTurningOffTheOption()throws Exception{ - enable();var old=store.begin("server","1");var gate=new CountDownLatch(1);var started=new CountDownLatch(1); - store.io.execute(()->{started.countDown();try{gate.await(5,TimeUnit.SECONDS);}catch(InterruptedException e){Thread.currentThread().interrupt();}}); - assertTrue(started.await(5,TimeUnit.SECONDS));Bitmap queued=solid(Color.RED);store.save(old,queued);store.clear(); - assertFalse(store.current(old));assertNull(store.begin("server","2"));gate.countDown();flush(); - Bitmap late=solid(Color.RED);store.save(old,late);flush();assertTrue(queued.isRecycled());assertTrue(late.isRecycled()); - assertFalse(store.directory.exists());assertNull(load("server","1"));assertTrue(store.enabled()); - var next=store.begin("server","1");store.save(next,solid(Color.BLUE));flush();assertNotNull(load("server","1")); - } - @Test public void deletingARecordRejectsPendingSaveAndDoesNotRemoveOtherPreviews()throws Exception{ - enable();var first=store.begin("server","1");var second=store.begin("server","2"); - store.save(first,solid(Color.RED));store.save(second,solid(Color.BLUE));flush(); - store.remove("server","1");store.save(first,solid(Color.GREEN));flush(); - assertNull(load("server","1"));Bitmap other=load("server","2");assertNotNull(other);other.recycle(); - assertFalse(new File(store.directory,first.key+".jpg").exists()); - } - @Test public void aNewConnectionSupersedesAnOlderCaptureForTheSameDevice()throws Exception{ - enable();var old=store.begin("server","1");var current=store.begin("server","1"); - store.save(current,solid(Color.GREEN));store.save(old,solid(Color.RED));flush(); - Bitmap loaded=load("server","1");assertNotNull(loaded);assertTrue(Color.green(loaded.getPixel(320,180))>240);loaded.recycle(); - } - @Test public void disablingDeletesFilesAndInvalidatesInFlightCapture()throws Exception{ - enable();var capture=store.begin("server","1");store.save(capture,solid(Color.BLUE));flush(); - store.setEnabled(false);store.save(capture,solid(Color.RED));flush(); - assertFalse(store.enabled());assertFalse(store.directory.exists());assertTrue(index.getAll().isEmpty());assertNull(load("server","1")); - store.setEnabled(true);assertNull(load("server","1")); - } - @Test public void failedCleanupHidesOldImagesAndCanBeRetried()throws Exception{ - enable();var capture=store.begin("server","1");store.save(capture,solid(Color.GREEN));flush(); - assertTrue(store.directory.setWritable(false,false));store.clear();flush();assertFalse(store.cleaning);assertFalse(store.cleanupError.isEmpty()); - assertNull(load("server","1"));assertTrue(store.directory.setWritable(true,false));store.clear();flush(); - assertEquals("",store.cleanupError);assertFalse(store.directory.exists()); - } - @Test public void corruptImagesAndFailedWritesNeverBecomePreviewCards()throws Exception{ - enable();assertTrue(store.directory.createNewFile());var capture=store.begin("server","1");store.save(capture,solid(Color.RED));flush();assertNull(load("server","1")); - assertTrue(store.directory.delete());store.save(capture,solid(Color.BLUE));flush(); - Files.write(new File(store.directory,capture.key+".jpg").toPath(),new byte[]{1,2,3});assertNull(load("server","1")); - } - @Test public void cropUsesCenteredSixteenByNineWithoutStretching(){ - assertEquals(new Rect(0,0,3840,2160),RemoteVideoView.previewCrop(3840,2160)); - assertEquals(new Rect(0,75,800,525),RemoteVideoView.previewCrop(800,600)); - assertEquals(new Rect(320,0,2240,1080),RemoteVideoView.previewCrop(2560,1080)); - assertEquals(new Rect(0,547,900,1053),RemoteVideoView.previewCrop(900,1600)); - } -} diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/SignalingLifecycleTest.java b/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/SignalingLifecycleTest.java deleted file mode 100644 index 04c597ed..00000000 --- a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/SignalingLifecycleTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package cn.crossdesk.mobile; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.widget.TextView; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; -import org.junit.*; -import org.junit.runner.RunWith; -import java.lang.reflect.*; -import java.util.Map; -import java.util.concurrent.FutureTask; -import static org.junit.Assert.*; - -/** Home authorization and lifecycle behavior; uses a local closed port, no public service. */ -@RunWith(AndroidJUnit4.class) -public class SignalingLifecycleTest { - private final android.app.Instrumentation instrumentation=InstrumentationRegistry.getInstrumentation(); - private MainActivity activity; - private SharedPreferences preferences; - private Map saved; - @Before public void start(){ - Context context=instrumentation.getTargetContext();preferences=context.getSharedPreferences("settings",Context.MODE_PRIVATE);saved=preferences.getAll(); - preferences.edit().clear().putString("host","127.0.0.1").putInt("port",1).putBoolean("networkConsent",false).commit(); - activity=(MainActivity)instrumentation.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } - @After public void finish()throws Exception{ - main(activity::finish); - SharedPreferences.Editor editor=preferences.edit().clear(); - for(var entry:saved.entrySet()){Object value=entry.getValue();if(value instanceof String)editor.putString(entry.getKey(),(String)value);else if(value instanceof Boolean)editor.putBoolean(entry.getKey(),(Boolean)value);else if(value instanceof Integer)editor.putInt(entry.getKey(),(Integer)value);} - editor.commit(); - } - private void main(Runnable action)throws Exception{ - FutureTask task=new FutureTask<>(()->{action.run();return null;});instrumentation.runOnMainSync(task);task.get();instrumentation.waitForIdleSync(); - } - private Object field(String name){try{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);}catch(Exception error){throw new RuntimeException(error);}} - private void call(String name){try{Method m=MainActivity.class.getDeclaredMethod(name);m.setAccessible(true);m.invoke(activity);}catch(Exception error){throw new RuntimeException(error);}} - @Test public void authorizedHomeConnectsAndBackgroundClosesPeer()throws Exception{ - main(()->{ - assertNull(field("signaling")); - preferences.edit().putBoolean("networkConsent",true).apply();call("ensureSignaling"); - NativeSession first=(NativeSession)field("signaling");assertNotNull(first); - assertNull("Home startup must not create a desktop session",field("session")); - call("ensureSignaling");assertSame("Navigation reuses the identity connection",first,field("signaling")); - activity.onStop();assertTrue(first.isClosed());assertNull(field("signaling")); - activity.onStart();NativeSession resumed=(NativeSession)field("signaling");assertNotNull(resumed);assertNotSame(first,resumed); - try{Field host=MainActivity.class.getDeclaredField("host");host.setAccessible(true);host.set(activity,"127.0.0.2");}catch(ReflectiveOperationException e){throw new RuntimeException(e);} - call("ensureSignaling");assertTrue("Server changes retire the old identity peer",resumed.isClosed());assertNotSame(resumed,field("signaling")); - activity.onStop();preferences.edit().putBoolean("networkConsent",false).apply();activity.onStart(); - assertNull("Revoked consent must prevent foreground reconnect",field("signaling")); - }); - } - @Test public void badgeTracksCallbacksAndFailureWithoutOpeningDesktop()throws Exception{ - main(()->{ - preferences.edit().putBoolean("networkConsent",true).apply(); - activity.signaling(0,"");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("正在连接")); - activity.signaling(1,"123456789");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("已连接服务器")); - activity.signaling(4,"123456789");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("正在重连")); - activity.ended("服务器 TLS 证书校验失败");assertTrue(((TextView)field("signalBadge")).getText().toString().contains("证书校验失败")); - assertNull(field("session"));assertEquals("home",field("page")); - }); - } -} diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/VideoSettingsTest.java b/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/VideoSettingsTest.java deleted file mode 100644 index 425f1619..00000000 --- a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/VideoSettingsTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package cn.crossdesk.mobile; - -import org.json.JSONObject; -import org.junit.Test; -import static org.junit.Assert.*; - -public class VideoSettingsTest { - private final RemoteVideoSettings.Values first=new RemoteVideoSettings.Values(1,30,2),second=new RemoteVideoSettings.Values(0,60,1); - private void same(RemoteVideoSettings.Values expected,RemoteVideoSettings.Values actual){assertEquals(expected.quality,actual.quality);assertEquals(expected.frameRate,actual.frameRate);assertEquals(expected.preference,actual.preference);} - @Test public void supportedHostsAndProtocolValuesOnly(){ - RemoteVideoSettings state=new RemoteVideoSettings(0);assertEquals(-1,state.begin(first));same(new RemoteVideoSettings.Values(1,30,0),state.selection);state.setSupported(true); - for(int q=-1;q<4;q++)for(int fps:new int[]{0,24,30,60,120})for(int p=-1;p<4;p++)assertEquals(q>=0&&q<=2&&(fps==30||fps==60)&&p>=0&&p<=2,state.begin(new RemoteVideoSettings.Values(q,fps,p))>=0); - } - @Test public void acknowledgementCommitsAndRejectionRollsBack(){ - RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first);assertTrue(state.pending);same(new RemoteVideoSettings.Values(1,30,1),state.applied); - state.receive(first,id,true);same(first,state.selection);assertFalse(state.pending);assertFalse(state.failed);state.expire(id);assertFalse(state.failed); - id=state.begin(second);state.receive(second,id,false);same(first,state.selection);assertTrue(state.failed);state.begin(second);assertFalse(state.failed); - } - @Test public void rapidChangesUseNewestSelectionAndLatestAcknowledgedRollback(){ - RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long a=state.begin(first),b=state.begin(second); - state.receive(first,a,true);state.expire(a);same(second,state.selection);assertTrue(state.pending); - state.receive(second,b,false);same(first,state.selection);assertTrue(state.failed); - long c=state.begin(second);state.receive(second,c,true);state.receive(first,a,true);state.receive(first,c+1,true);same(second,state.selection);same(second,state.applied); - } - @Test public void timeoutLateReplyAndCapabilityLoss(){ - RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first);state.expire(id);assertTrue(state.failed);same(new RemoteVideoSettings.Values(1,30,1),state.selection); - state.receive(first,id,true);same(first,state.selection);assertFalse(state.failed); - id=state.begin(second);state.setSupported(false);state.receive(second,id,true);state.expire(id);same(first,state.selection);assertFalse(state.failed);assertFalse(state.pending); - } - @Test public void malformedAndOutOfRangeAcknowledgementsCannotChangeState()throws Exception{ - RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first); - for(Object bad:new Object[]{-1L,0x100000000L,1.5,"1"}){JSONObject response=first.status(id,true);response.getJSONObject("video_settings").put("request_id",bad);state.receive(response);assertTrue(state.pending);} - JSONObject response=first.status(id,true);response.getJSONObject("video_settings").put("quality",0x100000001L);state.receive(response);assertTrue(state.pending); - response=first.status(id,true);response.getJSONObject("video_settings").put("accepted","true");state.receive(response);assertTrue(state.pending); - state.receive(first.status(id,true));assertFalse(state.pending);same(first,state.applied); - } - @Test public void requestIdsWrapAsUnsigned32Bit(){ - RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);state.requestId=state.appliedRequestId=0xfffffffeL; - long a=state.begin(first),b=state.begin(second);assertEquals(0xffffffffL,a);assertEquals(0,b); - state.receive(first,a,true);same(second,state.selection);state.receive(second,b,true);state.receive(first,a,true);same(second,state.applied); - } - @Test public void softwareDecodeStartsWithinDisplayBudgetAndAllowsManualOverrides(){ - for(int edge:new int[]{640,720,1280})same(new RemoteVideoSettings.Values(0,30,1),new RemoteVideoSettings(1,edge).selection); - for(int edge:new int[]{0,1281,1920,3840})same(new RemoteVideoSettings.Values(1,30,2),new RemoteVideoSettings(2,edge).selection); - RemoteVideoSettings state=new RemoteVideoSettings(-1,640);same(new RemoteVideoSettings.Values(0,30,1),state.selection); - state.setSupported(true);RemoteVideoSettings.Values high=new RemoteVideoSettings.Values(2,60,0);long id=state.begin(high); - state.receive(high,id,true);same(high,state.selection); - } -} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoSettings.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoSettings.java index 48b6acc5..717309f8 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoSettings.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoSettings.java @@ -23,8 +23,8 @@ final class RemoteVideoSettings { long requestId,appliedRequestId; RemoteVideoSettings(int preference){this(preference,1920);} RemoteVideoSettings(int preference,int displayLongEdge){ - // This Android pipeline uses software decoding. A 4K/60 startup can - // outrun mobile CPUs before the first settings acknowledgement arrives. + // Start within common mobile decoder limits. Hardware availability and + // maximum resolution vary, and AV1 may still need software fallback. // Small screens need no more than 720p; larger screens start at 1080p. // All quality/frame-rate choices remain available for manual adjustment. selection=applied=new Values(displayLongEdge>0&&displayLongEdge<=1280?0:1,30,preference>=0&&preference<=2?preference:1); diff --git a/apps/android/native/controller_protocol.h b/apps/android/native/controller_protocol.h index f835da27..ec9e8341 100644 --- a/apps/android/native/controller_protocol.h +++ b/apps/android/native/controller_protocol.h @@ -1,4 +1,11 @@ -#pragma once +/* + * @Author: DI JUNKUN + * @Date: 2026-10-02 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _CONTROLLER_PROTOCOL_H_ +#define _CONTROLLER_PROTOCOL_H_ #include #include @@ -24,3 +31,5 @@ inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, Travers {"total", traffic(stats.total_inbound_stats, stats.total_outbound_stats)}}; } } // namespace crossdesk::android_controller + +#endif \ No newline at end of file diff --git a/apps/android/native/latest_frame_worker.h b/apps/android/native/latest_frame_worker.h index 1c9911b8..f0a2974b 100644 --- a/apps/android/native/latest_frame_worker.h +++ b/apps/android/native/latest_frame_worker.h @@ -1,4 +1,11 @@ -#pragma once +/* + * @Author: DI JUNKUN + * @Date: 2026-10-02 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LATEST_FRAME_WORKER_H_ +#define _LATEST_FRAME_WORKER_H_ #include #include @@ -61,3 +68,5 @@ class LatestFrameWorker { std::thread thread_; }; } // namespace crossdesk::android_controller + +#endif \ No newline at end of file diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index 22266a0a..910c2e21 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -18,6 +18,7 @@ #include #include #include "controller_protocol.h" +#include "android_codec_support.h" #include "latest_frame_worker.h" #include "video_picture.h" #include "video_timing.h" @@ -63,6 +64,11 @@ struct Session { ANativeWindow* window = nullptr; uint32_t width = 0, height = 0; uint64_t frame_id = 0; + std::optional surface_picture; + void DetachDecoderSurface() { + if (surface_picture) surface_picture->RenderSurface(nullptr); + surface_picture.reset(); + } std::set pressed_keys; unsigned pressed_buttons = 0; float pointer_x = .5f, pointer_y = .5f; @@ -91,6 +97,7 @@ struct Session { std::lock_guard lock(render_mutex); ++video_generation; renderer.Clear(); + DetachDecoderSurface(); if (window) ANativeWindow_release(window); window = surface ? ANativeWindow_fromSurface(env, surface) : nullptr; surface_ready = window != nullptr; @@ -193,17 +200,28 @@ void RenderVideo(Session* s, VideoPicture picture) { const auto w = picture.width, h = picture.height; std::lock_guard lock(s->render_mutex); if (s->stopping || !s->window || picture.generation != s->video_generation) return; - if (s->width != w || s->height != h) { - if (ANativeWindow_setBuffersGeometry(s->window, w, h, WINDOW_FORMAT_RGBA_8888) != 0) return; + const bool size_changed = s->width != w || s->height != h; + if (picture.IsSurface()) { + if (picture.RenderSurface(s->window) != 0) return; + // Keep a consumed token to detach the codec before Surface destruction, + // monitor switching, or CPU rendering after a software fallback. + s->surface_picture = picture; + } else { + const bool was_surface = s->surface_picture.has_value(); + s->DetachDecoderSurface(); + if ((size_changed || was_surface) && + ANativeWindow_setBuffersGeometry(s->window, w, h, WINDOW_FORMAT_RGBA_8888) != 0) return; + ANativeWindow_Buffer buffer{}; + if (ANativeWindow_lock(s->window, &buffer, nullptr) != 0) return; + const bool rendered = buffer.width == static_cast(w) && buffer.height == static_cast(h) && + libyuv::NV12ToABGR(picture.Y(), picture.YStride(), picture.UV(), picture.UVStride(), + static_cast(buffer.bits), buffer.stride * 4, w, h) == 0; + if (ANativeWindow_unlockAndPost(s->window) != 0 || !rendered) return; + } + if (size_changed) { s->width = w; s->height = h; s->Event(5, {{"width", w}, {"height", h}}); } - ANativeWindow_Buffer buffer{}; - if (ANativeWindow_lock(s->window, &buffer, nullptr) != 0) return; - const bool rendered = buffer.width == static_cast(w) && buffer.height == static_cast(h) && - libyuv::NV12ToABGR(picture.Y(), picture.YStride(), picture.UV(), picture.UVStride(), - static_cast(buffer.bits), buffer.stride * 4, w, h) == 0; - if (ANativeWindow_unlockAndPost(s->window) != 0 || !rendered) return; // Measure through display submission, using MiniRTC's calibrated capture clock. const auto now = GetSystemTimeMicros(s->controller); const double latency = android_controller::VideoLatencyMilliseconds( @@ -219,9 +237,9 @@ Params Parameters(Session* s, bool controller) { std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s", s->host.c_str()); std::snprintf(p.log_path, sizeof(p.log_path), "%s", s->log_path.c_str()); p.signal_server_port = s->port; - p.hardware_acceleration = false; - // OpenH264/dav1d expose pooled CPU NV12 buffers on Android. Retain them - // across display submission instead of copying full-resolution pixels. + p.hardware_acceleration = true; + // MediaCodec returns retained Surface buffers; software fallback returns + // pooled CPU NV12 with the same ownership contract. p.native_video_output = controller; p.turn_mode = TurnAutoUdpTcp; p.enable_srtp = true; @@ -241,7 +259,9 @@ Session* From(jlong handle) { return reinterpret_cast(handle); } } // namespace extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* java_vm, void*) { - vm = java_vm; return JNI_VERSION_1_6; + vm = java_vm; + minirtc::android::SetJavaVm(java_vm); + return JNI_VERSION_1_6; } #define JNI_METHOD(name) Java_cn_crossdesk_mobile_NativeSession_##name extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nValidAppVersion)( @@ -274,7 +294,7 @@ extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)( s->host = String(env, host); s->port = port; s->identity_login = String(env, identity); s->log_path = String(env, log_path); // The bundle is exported from Android's default TrustManager, never the - // build machine's certificate store. The Android MiniRTC patch explicitly + // build machine's certificate store. MiniRTC on Android explicitly // loads this path: OpenSSL's default loader ignores SSL_CERT_FILE in an // Android app process (AT_SECURE=1). Chain and hostname checks stay enabled. const auto bundle = String(env, certificates); @@ -338,7 +358,7 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nControl)( auto* s = From(handle); if (!s) return; RemoteAction action{}; if (type == display_id && value >= 0 && value < 8) { - { std::lock_guard lock(s->render_mutex); s->display = value; ++s->video_generation; s->renderer.Clear(); s->width = s->height = 0; } + { std::lock_guard lock(s->render_mutex); s->display = value; ++s->video_generation; s->renderer.Clear(); s->DetachDecoderSurface(); s->width = s->height = 0; } action.type = display_id; action.d = value; } else if (type == audio_capture) { action.type = audio_capture; action.a = value != 0; diff --git a/apps/android/native/video_picture.h b/apps/android/native/video_picture.h index ead63f1e..52d4dffa 100644 --- a/apps/android/native/video_picture.h +++ b/apps/android/native/video_picture.h @@ -1,4 +1,11 @@ -#pragma once +/* + * @Author: DI JUNKUN + * @Date: 2026-10-02 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _VIDEO_PICTURE_H_ +#define _VIDEO_PICTURE_H_ #include #include @@ -14,6 +21,12 @@ struct VideoPicture { std::vector pixels; std::shared_ptr native_owner; MiniRtcCpuNv12Frame planes{}; + MiniRtcAndroidMediaCodecFrame media_codec{}; + + bool IsSurface() const { return media_codec.render && native_owner; } + int RenderSurface(void* window) const { + return IsSurface() ? media_codec.render(native_owner.get(), window) : -1; + } const uint8_t* Y() const { return native_owner ? planes.y_plane : pixels.data(); } const uint8_t* UV() const { return native_owner ? planes.uv_plane : pixels.data() + size_t(width) * height; } @@ -26,10 +39,17 @@ struct VideoPicture { if (!w || !h || w > 8192 || h > 8192 || (w & 1) || (h & 1) || static_cast(w) * h > 33554432) return std::nullopt; VideoPicture result{w, h, frame.captured_timestamp, frame.received_timestamp, - generation, {}, {}, {}}; + generation, {}, {}, {}, {}}; const auto* native = frame.native_frame; const bool valid_native = native && native->struct_size >= sizeof(*native) && native->width == w && native->height == h && native->owner; + if (valid_native && native->type == MiniRtcNativeVideoFrameAndroidMediaCodec && + native->retain && native->release && native->payload.android_media_codec.render) { + native->retain(native->owner); + result.native_owner = std::shared_ptr(native->owner, native->release); + result.media_codec = native->payload.android_media_codec; + return result; + } if (valid_native && native->type == MiniRtcNativeVideoFrameCpuNv12 && native->retain && native->release) { const auto& planes = native->payload.cpu_nv12; @@ -56,3 +76,5 @@ struct VideoPicture { } }; } // namespace crossdesk::android_controller + +#endif \ No newline at end of file diff --git a/apps/android/native/video_timing.h b/apps/android/native/video_timing.h index 3f11c2c5..222ddc0f 100644 --- a/apps/android/native/video_timing.h +++ b/apps/android/native/video_timing.h @@ -1,4 +1,11 @@ -#pragma once +/* + * @Author: DI JUNKUN + * @Date: 2026-10-02 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _VIDEO_TIMING_H_ +#define _VIDEO_TIMING_H_ #include @@ -16,3 +23,5 @@ inline double VideoLatencyMilliseconds(uint64_t captured_us, return (static_cast(submitted_us) - captured_us) / 1000.0; } } // namespace crossdesk::android_controller + +#endif \ No newline at end of file diff --git a/apps/android/native/xmake.lua b/apps/android/native/xmake.lua index 020735a2..5a1171ef 100644 --- a/apps/android/native/xmake.lua +++ b/apps/android/native/xmake.lua @@ -11,6 +11,8 @@ target("crossdesk_android") add_packages("nlohmann_json", "libyuv", "spdlog") add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp") add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include") + -- This source-level JNI adapter is the only application layer using MiniRTC internals. + add_includedirs("../../../deps/submodules/minirtc/src/media/mediacodec") add_syslinks("android", "log") add_shflags("-Wl,-z,max-page-size=16384", "-Wl,-z,common-page-size=16384", {force = true}) set_targetdir("../app/build/native/arm64-v8a") diff --git a/apps/android/app/src/androidTest/assets/tls/root.pem b/apps/android/test/android/assets/tls/root.pem similarity index 100% rename from apps/android/app/src/androidTest/assets/tls/root.pem rename to apps/android/test/android/assets/tls/root.pem diff --git a/apps/android/app/src/androidTest/assets/tls/valid.p12 b/apps/android/test/android/assets/tls/valid.p12 similarity index 100% rename from apps/android/app/src/androidTest/assets/tls/valid.p12 rename to apps/android/test/android/assets/tls/valid.p12 diff --git a/apps/android/app/src/androidTest/assets/tls/wrong-host.p12 b/apps/android/test/android/assets/tls/wrong-host.p12 similarity index 100% rename from apps/android/app/src/androidTest/assets/tls/wrong-host.p12 rename to apps/android/test/android/assets/tls/wrong-host.p12 diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/AnnouncementInboxTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/AnnouncementInboxTest.java similarity index 51% rename from apps/android/app/src/androidTest/java/cn/crossdesk/mobile/AnnouncementInboxTest.java rename to apps/android/test/android/java/cn/crossdesk/mobile/AnnouncementInboxTest.java index 9e7f0252..9a4e5241 100644 --- a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/AnnouncementInboxTest.java +++ b/apps/android/test/android/java/cn/crossdesk/mobile/AnnouncementInboxTest.java @@ -1,7 +1,5 @@ package cn.crossdesk.mobile; -import android.text.Spanned; -import android.text.style.URLSpan; import androidx.test.platform.app.InstrumentationRegistry; import org.json.*; import org.junit.*; @@ -28,7 +26,7 @@ public class AnnouncementInboxTest { main(()->inbox.close()); try(var paths=Files.walk(directory.toPath())){for(var path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);} } - static JSONObject answer(JSONObject request,int count,long revision)throws Exception { + private static JSONObject answer(JSONObject request,int count,long revision)throws Exception { int offset=request.getInt("offset");boolean summary=request.getBoolean("summary_only");JSONArray items=new JSONArray(); for(int i=offset;i{ - drain(45,1);AnnouncementInbox.Item old=inbox.items.get(0); - for(int i=0;i<20;i++){inbox.dismiss(inbox.items.get(0));drain(45,1);} - assertEquals(25,inbox.total);assertEquals(20,inbox.items.size());assertEquals(21,inbox.items.get(0).id); - inbox.refresh();drain(45,2);inbox.dismiss(old);assertTrue(inbox.deleteFailed);assertEquals(45,inbox.total); - });} - @Test public void catalogChangesRestartPagingAndIgnoreSupersededRequests()throws Exception {main(()->{ - JSONObject old=sent.remove();inbox.refresh();JSONObject current=sent.remove();inbox.receive(answer(old,203,1));assertTrue(inbox.loading);assertEquals(0,inbox.total); - inbox.receive(answer(current,203,1));inbox.receive(answer(sent.remove(),203,2)); - assertTrue(sent.peek().getBoolean("summary_only"));assertEquals(0,sent.peek().getInt("offset"));drain(203,2);assertEquals(203,inbox.unread); - inbox.refresh();JSONObject refresh=sent.remove();inbox.setConnected(false);inbox.receive(answer(refresh,0,3)); - assertEquals(20,inbox.items.size());assertFalse(inbox.loading); - inbox.setConnected(true);drain(0,3);assertEquals(0,inbox.items.size());assertTrue(inbox.loaded); - });} + @Test public void malformedResponseFailsWithoutLosingVisibleRowsAndCanRetry()throws Exception {main(()->{ drain(2,1);inbox.refresh();JSONObject invalid=answer(sent.remove(),2,1);invalid.put("total","2");inbox.receive(invalid); assertTrue(inbox.failed);assertFalse(inbox.loading);assertEquals(2,inbox.items.size()); @@ -82,38 +67,5 @@ public class AnnouncementInboxTest { body.getJSONArray("items").getJSONObject(0).put("body","中".repeat(2667));inbox.receive(body);assertTrue(inbox.failed);assertEquals(0,inbox.items.size()); inbox.refresh();drain(2,4);assertFalse(inbox.failed); });} - @Test public void duplicateBodiesAcrossPagesCannotCreateDuplicateRows()throws Exception {main(()->{ - drain(21,1);inbox.loadMore();JSONObject invalid=answer(sent.remove(),21,1);invalid.getJSONArray("items").getJSONObject(0).put("id",1); - inbox.receive(invalid);assertTrue(inbox.failed);assertEquals(20,inbox.items.size()); - });} - @Test public void sendFailureAndTimeoutAreMatchedToTheirRequest()throws Exception {main(()->{ - String obsolete=sent.remove().getString("request_id"); - var timeoutField=AnnouncementInbox.class.getDeclaredField("timeout");timeoutField.setAccessible(true);Runnable oldTimeout=(Runnable)timeoutField.get(inbox); - inbox.refresh();oldTimeout.run();inbox.failed(obsolete);assertTrue(inbox.loading);assertFalse(inbox.failed); - ((Runnable)timeoutField.get(inbox)).run();assertFalse(inbox.loading);assertTrue(inbox.failed);sent.clear(); - inbox.sender=(message,id)->inbox.failed(id);inbox.refresh();assertTrue(inbox.failed);assertFalse(inbox.loading); - inbox.setConnected(false);inbox.refresh();assertFalse(inbox.loading);assertTrue(sent.isEmpty()); - });} - @Test public void diskFailureKeepsUnreadAndVisibleState()throws Exception {main(()->{ - inbox.close();File blocked=new File(directory,"not-a-directory");assertTrue(blocked.createNewFile()); - inbox=new AnnouncementInbox(blocked);inbox.sender=(message,id)->sent.add(message);sent.clear(); - inbox.configure("example.test",9099,"123456789");inbox.setConnected(true);drain(1,1); - AnnouncementInbox.Item item=inbox.items.get(0);inbox.markRead(item);assertTrue(inbox.readSaveFailed);assertFalse(item.read);assertEquals(1,inbox.unread); - inbox.dismiss(item);assertTrue(inbox.deleteFailed);assertEquals(1,inbox.items.size()); - assertTrue(blocked.delete());inbox.markRead(item);assertFalse(inbox.readSaveFailed);assertTrue(item.read); - });} - @Test public void resetClearsMemoryAndRejectsOldServerResponses()throws Exception {main(()->{ - drain(1,1);inbox.refresh();JSONObject old=sent.remove();inbox.reset();inbox.receive(answer(old,1,1)); - assertEquals(0,inbox.unread);assertEquals(0,inbox.items.size());assertFalse(inbox.isConnected());assertFalse(inbox.loading); - inbox.refresh();assertTrue(sent.isEmpty()); - });} - @Test public void textKeepsPlainContentAndOnlyCreatesWebLinks(){ - String body="普通文本\r\n[官网](https://crossdesk.cn/path_(one)),https://example.com/a?b=c!\n[java](javascript:alert(1))"; - Spanned formatted=(Spanned)AnnouncementText.format(body); - assertEquals("普通文本\n官网,https://example.com/a?b=c!\n[java](javascript:alert(1))",formatted.toString()); - URLSpan[] links=formatted.getSpans(0,formatted.length(),URLSpan.class);assertEquals(2,links.length); - assertEquals("https://crossdesk.cn/path_(one)",links[0].getURL());assertEquals("https://example.com/a?b=c",links[1].getURL()); - for(String bad:new String[]{"javascript:alert(1)","file:///data/test","intent://example","https://","https://example.com\\evil","https://example.com\n","https://"})assertFalse(bad,AnnouncementText.isWebURL(bad)); - assertTrue(AnnouncementText.isWebURL("HTTPS://example.com/path")); - } + } diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/ControllerTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/ControllerTest.java new file mode 100644 index 00000000..f28eab96 --- /dev/null +++ b/apps/android/test/android/java/cn/crossdesk/mobile/ControllerTest.java @@ -0,0 +1,88 @@ +package cn.crossdesk.mobile; + +import android.content.Context; +import android.view.KeyEvent; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import java.lang.reflect.Field; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import static org.junit.Assert.*; + +@RunWith(AndroidJUnit4.class) +public class ControllerTest { + + @Test public void repeatedNativeConnectedEventsNotifyAndInitializeOnlyOnce()throws Exception{ + var instrumentation=InstrumentationRegistry.getInstrumentation(); + var notifications=new java.util.concurrent.atomic.AtomicInteger(); + NativeSession.Listener listener=new NativeSession.Listener(){ + public void status(String value){}public void connected(){notifications.incrementAndGet();}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){} + }; + NativeSession session=new NativeSession(instrumentation.getTargetContext(),"127.0.0.1",1,"123456789","test",listener); + Field workerField=NativeSession.class.getDeclaredField("RTC"),audioField=NativeSession.class.getDeclaredField("audio");workerField.setAccessible(true);audioField.setAccessible(true); + ExecutorService worker=(ExecutorService)workerField.get(null); + var event=NativeSession.class.getDeclaredMethod("onNativeEvent",int.class,byte[].class);event.setAccessible(true); + byte[] connected="{\"status\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + try{ + // Exercise the Java native-callback path without starting a network peer. + event.invoke(session,2,connected);Object audio=worker.submit(()->audioField.get(session)).get(5,TimeUnit.SECONDS); + instrumentation.waitForIdleSync();assertEquals(1,notifications.get());assertNotNull(audio); + event.invoke(session,2,connected);event.invoke(session,2,connected); + assertSame("Duplicate readiness must not replace the audio player",audio,worker.submit(()->audioField.get(session)).get(5,TimeUnit.SECONDS)); + instrumentation.waitForIdleSync();assertEquals("One desktop connection must notify the UI only once",1,notifications.get()); + }finally{session.close();worker.submit(()->{}).get(5,TimeUnit.SECONDS);} + } + @Test public void keyboardSupportsDesktopVirtualKeys() { + assertEquals(0x41, KeyMap.windowsCode(KeyEvent.KEYCODE_A)); + assertEquals(0x2E, KeyMap.windowsCode(KeyEvent.KEYCODE_FORWARD_DEL)); + assertEquals(0x11, KeyMap.windowsCode(KeyEvent.KEYCODE_CTRL_LEFT)); + assertEquals(0x5B, KeyMap.windowsCode(KeyEvent.KEYCODE_META_LEFT)); + assertEquals(0xDB | 0x100, KeyMap.ascii('{')); + assertEquals(0x31 | 0x100, KeyMap.ascii('!')); + assertEquals(0, KeyMap.ascii('中')); + } + + @Test public void credentialsRemainEncryptedAndServerScoped() throws Exception { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + SecretStore store = new SecretStore(context); + String scope = "instrumentation.invalid:9099"; + try { + store.put(scope, "test-id@temporary-test-password"); + assertEquals("test-id@temporary-test-password", store.get(scope)); + assertEquals("", store.get("instrumentation.invalid:9100")); + String persisted = context.getSharedPreferences("identities", Context.MODE_PRIVATE).getString(scope, ""); + assertFalse(persisted.contains("temporary-test-password")); + } finally { + context.getSharedPreferences("identities", Context.MODE_PRIVATE).edit().remove(scope).commit(); + } + } + + @Test public void nativePeerCanStartAndCancelRepeatedly() throws Exception { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + Field workerField = NativeSession.class.getDeclaredField("RTC"); workerField.setAccessible(true); + ExecutorService worker = (ExecutorService) workerField.get(null); + Field handleField = NativeSession.class.getDeclaredField("handle"); handleField.setAccessible(true); + NativeSession.Listener listener = new NativeSession.Listener() { + public void status(String value) { } + public void connected() { fail("Loopback port 1 must not establish a session"); } + public void ended(String reason) { } + public void data(JSONObject data) { } + public void videoSize(int width, int height) { } + public void clipboard(String value) { } + public void statistics(RemoteNetworkStatistics.Snapshot value) { } + }; + for (int i = 0; i < 3; i++) { + NativeSession session = new NativeSession(context, "127.0.0.1", 1, "invalid", "test", listener); + session.start(); + assertTrue(worker.submit(() -> handleField.getLong(session)).get(15, TimeUnit.SECONDS) != 0); + session.surface(null); + session.close(); session.close(); + assertEquals(0L, (long) worker.submit(() -> handleField.getLong(session)).get(15, TimeUnit.SECONDS)); + } + } + +} diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/NetworkStatisticsTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/NetworkStatisticsTest.java new file mode 100644 index 00000000..694a6223 --- /dev/null +++ b/apps/android/test/android/java/cn/crossdesk/mobile/NetworkStatisticsTest.java @@ -0,0 +1,24 @@ +package cn.crossdesk.mobile; + +import org.json.JSONObject; +import org.junit.Test; +import static org.junit.Assert.*; + +public class NetworkStatisticsTest { + static JSONObject report(int mode,boolean srtp,double rtt)throws Exception{ + return new JSONObject().put("mode",mode).put("srtp",srtp).put("rtt",rtt).put("video",traffic(8000000,0,.02)).put("audio",traffic(64000,0,.01)).put("data",traffic(2000,3000,.03)).put("total",traffic(8066000,3000,.06)); + } + private static JSONObject traffic(long in,long out,double loss)throws Exception{return new JSONObject().put("inbound",in).put("outbound",out).put("loss",loss);} + + @Test public void staleReportsAndRttExpireIndependently()throws Exception{ + RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.receive(new RemoteNetworkStatistics.Report(report(0,true,20)),10);state.receive(new RemoteNetworkStatistics.Report(report(0,true,100)),11);assertEquals(40,state.snapshot(11).rtt,.0001); + for(double invalid:new double[]{-1,2001})state.receive(new RemoteNetworkStatistics.Report(report(0,true,invalid)),12); + assertEquals(40,state.snapshot(13.99).rtt,.0001);assertTrue(Double.isNaN(state.snapshot(14).rtt));assertNotNull(state.snapshot(14).report);assertNull(state.snapshot(15).report); + state.receive(new RemoteNetworkStatistics.Report(report(0,true,0)),16);assertEquals(0,state.snapshot(16).rtt,0); + } + @Test public void submittedFramesAreCountedOnceAndIdleVideoExpires(){ + RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.frame(1,100,1920,1080,10);state.frame(1,100,1920,1080,10.1);state.frame(2,200,1920,1080,10.2); + assertEquals(2,state.snapshot(10.3).fps);assertEquals(150,state.snapshot(10.3).latency,.0001);assertEquals("1920 × 1080",state.snapshot(10.3).resolution());assertEquals(1,state.snapshot(11).fps);assertEquals(0,state.snapshot(11.3).fps);assertTrue(Double.isNaN(state.snapshot(11.3).latency)); + } + +} diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/RemotePreviewStoreTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/RemotePreviewStoreTest.java new file mode 100644 index 00000000..f2e15e69 --- /dev/null +++ b/apps/android/test/android/java/cn/crossdesk/mobile/RemotePreviewStoreTest.java @@ -0,0 +1,59 @@ +package cn.crossdesk.mobile; + +import android.content.*; +import android.graphics.*; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.*; +import java.io.*; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.Assert.*; + +public class RemotePreviewStoreTest { + private Context context; + private File root; + private SharedPreferences options,index; + private String prefix; + private RemotePreviewStore store; + @Before public void setup()throws Exception{ + context=InstrumentationRegistry.getInstrumentation().getTargetContext();prefix="preview-test-"+UUID.randomUUID(); + options=context.getSharedPreferences(prefix,0);index=context.getSharedPreferences(prefix+"-index",0); + root=Files.createTempDirectory(context.getCacheDir().toPath(),"previews-").toFile(); + store=new RemotePreviewStore(new File(root,"images"),options,index); + } + @After public void cleanup()throws Exception{ + store.clear();flush();store.io.shutdown(); + try(var paths=Files.walk(root.toPath())){for(var path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);} + context.deleteSharedPreferences(prefix);context.deleteSharedPreferences(prefix+"-index"); + } + private void flush()throws Exception{store.io.submit(()->{}).get(5,TimeUnit.SECONDS);InstrumentationRegistry.getInstrumentation().waitForIdleSync();} + private void enable(){options.edit().putBoolean("networkConsent",true).commit();store.setEnabled(true);} + private static Bitmap solid(int color){Bitmap image=Bitmap.createBitmap(640,360,Bitmap.Config.ARGB_8888);image.eraseColor(color);return image;} + private Bitmap load(String server,String id)throws Exception{ + var result=new java.util.concurrent.atomic.AtomicReference();var done=new CountDownLatch(1); + store.load(server,id,image->{result.set(image);done.countDown();});assertTrue(done.await(5,TimeUnit.SECONDS));return result.get(); + } + @Test public void offByDefaultAndConsentCannotBeBypassed()throws Exception{ + assertFalse(store.enabled());assertNull(store.begin("server","1"));store.setEnabled(true);flush();assertFalse(store.enabled()); + options.edit().putBoolean("networkConsent",true).commit();assertFalse(store.enabled()); + enable();assertNotNull(store.begin("server","1")); + options.edit().putBoolean("networkConsent",false).commit();store.enforceConsent();flush(); + assertFalse(options.getBoolean(RemotePreviewStore.OPTION,true));assertNull(store.begin("server","1")); + } + @Test public void jpegPersistsAcrossStoreRecreationAndRemainsServerScoped()throws Exception{ + enable();var capture=store.begin("server:1","123456789");store.save(capture,solid(Color.GREEN));flush(); + assertTrue(new File(store.directory,capture.key+".jpg").isFile()); + assertNull(load("server:2","123456789"));assertNull(load("server:1","other")); + store.io.shutdown();store=new RemotePreviewStore(store.directory,options,index); + Bitmap loaded=load("server:1","123456789");assertNotNull(loaded);assertEquals(640,loaded.getWidth());assertEquals(360,loaded.getHeight());assertTrue(Color.green(loaded.getPixel(320,180))>240);loaded.recycle(); + } + + @Test public void disablingDeletesFilesAndInvalidatesInFlightCapture()throws Exception{ + enable();var capture=store.begin("server","1");store.save(capture,solid(Color.BLUE));flush(); + store.setEnabled(false);store.save(capture,solid(Color.RED));flush(); + assertFalse(store.enabled());assertFalse(store.directory.exists());assertTrue(index.getAll().isEmpty());assertNull(load("server","1")); + store.setEnabled(true);assertNull(load("server","1")); + } + +} diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/RemoteVersionCheckTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/RemoteVersionCheckTest.java similarity index 60% rename from apps/android/app/src/androidTest/java/cn/crossdesk/mobile/RemoteVersionCheckTest.java rename to apps/android/test/android/java/cn/crossdesk/mobile/RemoteVersionCheckTest.java index dc3edb2e..4e6a044d 100644 --- a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/RemoteVersionCheckTest.java +++ b/apps/android/test/android/java/cn/crossdesk/mobile/RemoteVersionCheckTest.java @@ -38,14 +38,6 @@ public class RemoteVersionCheckTest { assertTrue(NativeSession.nHasAppUpdate("1.5.3-9", json("{\"version\":\"1.5.3\",\"patch\":\"10\"}"))); } - @Test public void invalidReleaseMetadataNeverRaisesANotice() { - for (String release : new String[]{"", "not JSON", "{}", "[]", "null", "{\"version\":false}", - "{\"version\":\"unknown\"}", "{\"version\":\"1..2\"}", "{\"version\":\"999999999999999.2\"}"}) - assertFalse(release, NativeSession.nHasAppUpdate("1.0.0", json(release))); - assertFalse(NativeSession.nHasAppUpdate("1.0.0", null)); - assertFalse(NativeSession.nHasAppUpdate("1.0.0", new byte[256 * 1024 + 1])); - } - @Test public void legacyVersionWaitsForConnectionAndNeverFetches() { for (Object version : new Object[]{null, JSONObject.NULL, 123, "", " ", "unknown", "ios-native", "1..5", "1.5-"}) { Fetches fetches = new Fetches(); int[] notices = {0}; @@ -57,21 +49,6 @@ public class RemoteVersionCheckTest { } } - @Test public void bothEventOrdersCheckOncePerSession() { - for (boolean hostFirst : new boolean[]{true, false}) { - Fetches fetches = new Fetches(); int[] notices = {0}; - RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++); - if (hostFirst) check.hostInfo(host("1.0.0")); else check.connected(); - assertTrue(fetches.replies.isEmpty()); - if (hostFirst) check.connected(); else check.hostInfo(host("1.0.0")); - check.hostInfo(host("1.0.0")); check.connected(); assertEquals(1, fetches.replies.size()); - fetches.replies.get(0).accept(json("{\"latest_version\":\"2.0.0\"}")); assertEquals(1, notices[0]); - check.hostInfo(host("1.0.0")); assertEquals(1, notices[0]); - check.reset(); check.connected(); assertEquals(1, fetches.replies.size()); - check.hostInfo(host("1.0.0")); assertEquals(2, fetches.replies.size()); check.reset(); - } - } - @Test public void disconnectCancelsAndOldReplyCannotAffectANewPeer() { Fetches fetches = new Fetches(); int[] notices = {0}; RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++); @@ -84,13 +61,4 @@ public class RemoteVersionCheckTest { check.reset(); assertEquals(1, fetches.cancellations); } - @Test public void failedCheckIsSilentAndDoesNotRetryOnDuplicateHostInfo() { - for (byte[] reply : new byte[][]{null, json("invalid"), json("{\"version\":\"1.0.0\"}")}) { - Fetches fetches = new Fetches(); int[] notices = {0}; - RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++); - check.connected(); check.hostInfo(host("1.0.0")); fetches.replies.get(0).accept(reply); - check.hostInfo(host("1.0.0")); assertEquals(0, notices[0]); assertEquals(1, fetches.replies.size()); - check.reset(); - } - } } diff --git a/apps/android/app/src/androidTest/java/cn/crossdesk/mobile/TlsTrustTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/TlsTrustTest.java similarity index 100% rename from apps/android/app/src/androidTest/java/cn/crossdesk/mobile/TlsTrustTest.java rename to apps/android/test/android/java/cn/crossdesk/mobile/TlsTrustTest.java diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/VideoSettingsTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/VideoSettingsTest.java new file mode 100644 index 00000000..a86ce822 --- /dev/null +++ b/apps/android/test/android/java/cn/crossdesk/mobile/VideoSettingsTest.java @@ -0,0 +1,22 @@ +package cn.crossdesk.mobile; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class VideoSettingsTest { + private final RemoteVideoSettings.Values first=new RemoteVideoSettings.Values(1,30,2),second=new RemoteVideoSettings.Values(0,60,1); + private void same(RemoteVideoSettings.Values expected,RemoteVideoSettings.Values actual){assertEquals(expected.quality,actual.quality);assertEquals(expected.frameRate,actual.frameRate);assertEquals(expected.preference,actual.preference);} + + @Test public void acknowledgementCommitsAndRejectionRollsBack(){ + RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first);assertTrue(state.pending);same(new RemoteVideoSettings.Values(1,30,1),state.applied); + state.receive(first,id,true);same(first,state.selection);assertFalse(state.pending);assertFalse(state.failed);state.expire(id);assertFalse(state.failed); + id=state.begin(second);state.receive(second,id,false);same(first,state.selection);assertTrue(state.failed);state.begin(second);assertFalse(state.failed); + } + + @Test public void timeoutLateReplyAndCapabilityLoss(){ + RemoteVideoSettings state=new RemoteVideoSettings(1);state.setSupported(true);long id=state.begin(first);state.expire(id);assertTrue(state.failed);same(new RemoteVideoSettings.Values(1,30,1),state.selection); + state.receive(first,id,true);same(first,state.selection);assertFalse(state.failed); + id=state.begin(second);state.setSupported(false);state.receive(second,id,true);state.expire(id);same(first,state.selection);assertFalse(state.failed);assertFalse(state.pending); + } + +} diff --git a/apps/android/native/tests/controller_protocol_test.cpp b/apps/android/test/native/controller_protocol_test.cpp similarity index 100% rename from apps/android/native/tests/controller_protocol_test.cpp rename to apps/android/test/native/controller_protocol_test.cpp diff --git a/apps/android/native/tests/video_pipeline_test.cpp b/apps/android/test/native/video_pipeline_test.cpp similarity index 100% rename from apps/android/native/tests/video_pipeline_test.cpp rename to apps/android/test/native/video_pipeline_test.cpp diff --git a/apps/android/scripts/test_protocol.py b/apps/android/test/run_native.py similarity index 91% rename from apps/android/scripts/test_protocol.py rename to apps/android/test/run_native.py index b8a6d91a..eec581d1 100644 --- a/apps/android/scripts/test_protocol.py +++ b/apps/android/test/run_native.py @@ -17,14 +17,14 @@ with tempfile.TemporaryDirectory(prefix='crossdesk-android-protocol-') as tempor '-I' + str(ANDROID / 'native'), '-I' + str(ROOT / 'deps/submodules/minirtc/src/api'), '-I' + str(ROOT / 'libs/wire/include'), - str(ANDROID / 'native/tests/controller_protocol_test.cpp'), + str(ANDROID / 'test/native/controller_protocol_test.cpp'), str(ROOT / 'libs/wire/src/remote_action.cpp'), '-o', str(binary)], check=True) subprocess.run([str(binary)], check=True) video_test = Path(temporary) / 'video-pipeline-test' subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17', '-pthread', '-I' + str(ANDROID / 'native'), '-I' + str(ROOT / 'deps/submodules/minirtc/src/api'), - str(ANDROID / 'native/tests/video_pipeline_test.cpp'), + str(ANDROID / 'test/native/video_pipeline_test.cpp'), '-o', str(video_test)], check=True) subprocess.run([str(video_test)], check=True, timeout=10) print('PASS: Android video settings / desktop wire round-trip and native network fields') diff --git a/apps/android/scripts/verify_apk.py b/apps/android/test/verify_apk.py similarity index 100% rename from apps/android/scripts/verify_apk.py rename to apps/android/test/verify_apk.py diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index bfefece5..8d5dd9ad 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit bfefece59a739e5f116aeacb95c389002d8e2b12 +Subproject commit 8d5dd9ad8cdf327cbacbd2986663fd04ce18e4c7