mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 18:03:08 +08:00
[feat] add native Android controller
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
gradlew text eol=lf
|
||||
gradlew.bat text eol=crlf
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
.native/
|
||||
.idea/
|
||||
**/build/
|
||||
local.properties
|
||||
*.iml
|
||||
__pycache__/
|
||||
@@ -0,0 +1,140 @@
|
||||
# CrossDesk Android
|
||||
|
||||
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 0.1.0,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。
|
||||
|
||||
## 当前功能
|
||||
|
||||
- 官方或自托管 WSS 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权或进入后台会关闭连接。
|
||||
- H.264(OpenH264)和 AV1(dav1d)软件解码,独立显示线程将 NV12 转 RGBA 后提交 Android 原生 Surface;只保留最新待显示帧,避免 Surface 等待阻塞解码。压缩视频帧使用 MiniRTC 原有的通用解码队列,暂未限制排队长度或等待时间。
|
||||
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
|
||||
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键、双指滚轮,以及外接键盘、鼠标。
|
||||
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
|
||||
- 根据 Android 软件解码和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
|
||||
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
||||
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
|
||||
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
|
||||
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码底部弹层、两列最近连接及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
|
||||
- 页面转场沿用 iOS 的交互方式:设置、隐私、关于及公告页面进入时从右滑入,返回时反向滑出,背景页面轻微跟随;进入和退出远程会话使用 0.2 秒淡入淡出。遵循 Android 系统动画开关及速度设置。
|
||||
- 设置页的鼠标控制、画面偏好,以及悬浮控制栏的画质、帧率和画面偏好使用 0.2 秒的选中底块滑动动画;连续点击从当前位置转向,电脑端拒绝或超时后平滑回退,系统关闭动画时直接切换。
|
||||
- 通知公告与 iOS 共用信令协议,支持未读角标、分页、下拉刷新、更新推送、详情和网页链接;已读及删除状态按服务器、本机身份和公告版本保存在本机。
|
||||
- 连接建立并收到对端信息后,按 iOS 相同的版本号及补丁规则检查桌面端版本;旧版本弹出“升级提示”,点击“知道了”继续控制。每次连接最多检查、提醒一次;缺失或无效版本按旧版提醒,检查失败不打断连接。仅获取公共 HTTPS 版本元数据,不上传对端身份或版本;未授权不请求,断开及进入后台取消检查并忽略过期回复。
|
||||
- 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。
|
||||
- “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。
|
||||
|
||||
当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令、捏合缩放或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。H.264 / AV1 尚未使用 MediaCodec 硬件解码,高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
|
||||
|
||||
## 构建
|
||||
|
||||
在 macOS 或 Linux 安装:
|
||||
|
||||
- JDK 17 或更新版本(可使用 Android Studio 自带 JBR)。
|
||||
- Android SDK Platform 36、Build Tools 36.0.0、NDK `28.2.13676358`。
|
||||
- Xmake **3.1.1**、Python 3.9+、Git、CMake 和主机编译工具。
|
||||
- Python 的 setuptools、wheel,用于构建依赖的宿主工具。
|
||||
|
||||
先在仓库根目录初始化子模块:
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
macOS 命令行示例:
|
||||
|
||||
```sh
|
||||
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
||||
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
# 若 PATH 中的 Xmake 不是 3.1.1,通过 XMAKE_BIN 指定对应可执行文件。
|
||||
# 构建器也识别 ~/.cache/crossdesk/toolchains/xmake-3.1.1/xmake。
|
||||
cd apps/android
|
||||
./gradlew :app:assembleDebug
|
||||
```
|
||||
|
||||
也可用 Android Studio 打开 `apps/android`。在 SDK Manager 安装上述版本;SDK 位置可由 IDE 写入被忽略的 `local.properties`。Xmake 不在 IDE 的 PATH 中时,启动 IDE 前设置 `XMAKE_BIN`。
|
||||
|
||||
输出为 `app/build/outputs/apk/debug/app-debug.apk`,已由本机 Android 调试密钥签名:
|
||||
|
||||
```sh
|
||||
"$ANDROID_HOME/platform-tools/adb" install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 APK;发行前需配置自己的签名。Debug APK 也使用优化后的 Release 原生库,以保证软件解码性能。
|
||||
|
||||
首次构建会下载并从源码编译依赖,耗时取决于网络。后续编译复用缓存。Android 直接编译 `deps/submodules/minirtc` 中的源码;构建产物和依赖缓存位于 `.native/`,Xmake 工程配置位于 `native/.xmake/`,与桌面和 iOS 分开保存:
|
||||
|
||||
- Gradle 9.3.1、AGP 9.1.0、NDK r28c 固定在工程中。
|
||||
- Xmake 包仓库固定到 `eda39de3fb99b420c168f1ab9ab2d1791e11b662`。
|
||||
- `scripts/build_native.py` 调用独立的 `native/xmake.lua`,直接包含 MiniRTC 子模块的构建定义。构建过程不复制 MiniRTC 源码,也不应用补丁。
|
||||
- MiniRTC 源码内的 Android 分支启用 OpenH264、SVT-AV1 和 dav1d 软件编解码及依赖配方,兼容 OpenFEC 的 Android 头文件,并关闭 UPnP。SVT-AV1 使用内置 ARM CPU 能力检测;当前应用作为控制端只接收视频,库同时提供 AV1 编码能力。TLS 校验位于 MiniRTC 内部。STUN、TURN UDP/TCP 与直接连接仍由 MiniRTC 提供。
|
||||
- MiniRTC 的 `thirdparty/openssl/xmake.lua` 统一维护 iOS 和 Android 配方;Android 显式使用 NDK 的归档索引工具,避免 macOS `ranlib` 损坏 ELF 静态库。
|
||||
- SVT-AV1 编码及 dav1d 解码已在 Android 15 arm64 / 16 KB 模拟器上完成验证,覆盖软件编码、请求硬件加速时的软件回退、码率更新、强制关键帧及 320×192 → 256×144 → 320×192 分辨率切换;两组测试各成功编码并解码 24 帧。这是编解码正确性验证,不代表真机实时编码性能。
|
||||
- 依赖静态链接到 `libcrossdesk_android.so`,保留 16 KB ELF / APK 对齐。
|
||||
|
||||
修改原生代码后重新运行 Gradle 即可;修改依赖版本或配方时同步检查许可证内容,必要时移除 `.native/packages` 重新构建。MiniRTC 的源码和依赖配方变更直接保存在子模块中;提交时先提交 MiniRTC,再更新主仓库的子模块引用。
|
||||
|
||||
## 使用与连接安全
|
||||
|
||||
授权后首页会自动连接服务器,绿色“已连接服务器”表示信令登录完成;失败时可点击状态重试。设置页显示本机 ID。电脑端保持在线,在 Android 首页填写 9 位设备 ID,首次点击“连接”后在底部弹层输入密码,可选择加密保存密码。成功保存后,点击最近连接或再次输入相同 ID 会直接使用该服务器下保存的密码,重启应用后仍然有效;密码无法读取或被远端拒绝时会重新显示输入弹层,新密码仅在连接成功后替换,取消或连接失败不会覆盖原密码。自托管服务器从“设置 → 服务器”展开修改主机名与端口,不能填写 `https://`、路径或完整 URL。默认是 `api.crossdesk.cn:9099`。
|
||||
|
||||
证书信任来自 Android 默认 TrustManager 导出的根证书。JNI 设置证书文件路径后,MiniRTC 的 Android 分支通过 `SSL_CTX_load_verify_locations` 显式加载,并校验证书链及 DNS / IP 主机名;不会跳过 TLS 验证。Android 应用进程带有 `AT_SECURE` 标记,不能依赖 OpenSSL 的默认路径加载器读取 `SSL_CERT_FILE`,否则会出现所有服务器均无法建立信任链的问题。自托管服务器应使用受系统信任的证书。当前没有在应用内导入自签名证书的入口。
|
||||
|
||||
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
|
||||
|
||||
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。浮动键盘切换“电脑”后的“复制 / 粘贴 / 全选”根据远端平台选择 Ctrl 或 macOS Command。
|
||||
|
||||
首页设置里的“画面偏好”保存在本机,作为下一次连接的默认偏好;会话内画面设置与 iOS 一样只影响当前连接。初始质量按屏幕长边选择低(不超过 1280 像素)或中,采集帧率为 30 fps,电脑端报告支持后自动下发;旧版电脑未报告支持时禁用控件。网络页的 FPS 统计实际提交给 Android Surface 的帧,画面延时使用 MiniRTC 校准后的采集时间至提交时刻的估计值,未完成校准时显示“—”;RTT 使用 0.25 权重平滑,3 秒未更新的数据会过期。切换屏幕清除旧画面的帧率、分辨率和画面延时,保留网络统计。
|
||||
|
||||
采集时间的有效性在接收时检查,本机解码和显示等待计入画面延时;真实延时超过 5 秒也会继续显示,不会当成无效时间戳过滤。显示线程复用解码器的 CPU NV12 缓冲区,只保留一帧待显示画面,切换显示器或 Surface 时清除旧帧,会话结束时先停止显示线程再销毁原生连接。
|
||||
|
||||
## 远程画面预览
|
||||
|
||||
设置 → 隐私中的“保存远程画面预览”默认关闭,完成联网授权后可开启。与 iOS 一致,开启前说明预览可能包含个人或工作信息;每次连接只在实际显示画面后保存一张预览,居中裁剪为 640×360、JPEG 质量 78。截图来自远程视频的 Surface,不包含悬浮菜单、键盘、本地光标或画面外的黑边;压缩、读写和解码在串行后台线程执行,不复制完整 4K 位图,也不连续截图。
|
||||
|
||||
图片保存在应用私有、不参与备份的 `RecentConnectionThumbnails` 目录,不上传、不写入系统相册。文件按服务器与远端 ID 隔离,最近连接卡片异步加载;未开启、没有预览或图片损坏时继续显示平台封面。删除或淘汰连接记录会删除对应预览。
|
||||
|
||||
“清除预览图”保留连接记录、密码和开关状态,下一次连接仍可保存新的预览。关闭保存选项或撤回联网授权会关闭保存并清除全部预览。清除时立即使未完成的截图和读取请求失效,删除与保存共用串行队列,防止已清除的图片被旧截图重新写回;清除失败会显示提示,可再次重试。
|
||||
|
||||
## 通知公告
|
||||
|
||||
首页铃铛展示全部未读公告的数量(超过 99 条显示 99+)。授权并连接服务器后,通过已有身份信令请求 `announcements_list`,摘要每页 200 条、正文每页 20 条;不依赖远程桌面连接。列表支持刷新、滚动加载更多、长按或左滑删除,删除前需确认,且仅隐藏本机的当前版本。打开详情即标记已读;服务器更新公告版本后会重新显示并计为未读。详情正文保留纯文本和换行,支持 HTTP(S) 地址及 `[文字](https://...)` 链接,不执行 HTML 或其他协议。
|
||||
|
||||
阅读和删除标记以原子文件保存在不参与备份的 `AnnouncementReads` 目录,按主机名、端口和本机设备 ID 隔离;写入失败会提示并保留原状态。内容只缓存于内存。收到 `announcements_changed` 时刷新,分页期间目录版本改变时重新获取;过期请求、断线前的回复不会覆盖新状态。请求失败或 12 秒超时可重试;未授权不联网,进入后台停止请求。自托管服务需要支持与桌面及 iOS 相同的公告协议。
|
||||
|
||||
## 验证
|
||||
|
||||
连接 arm64 设备或启动 arm64 模拟器后,在 `apps/android` 执行:
|
||||
|
||||
```sh
|
||||
./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :app:lintDebug
|
||||
"$ANDROID_HOME/platform-tools/adb" install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
"$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
|
||||
```
|
||||
|
||||
以上流程覆盖安装应用并只卸载测试包,保留已有应用数据;多台设备同时连接时,在每个 `adb` 命令中加上 `-s <设备序列号>`。`connectedDebugAndroidTest` 可能卸载被测应用及其数据,不用于已有连接记录或密码的设备。
|
||||
|
||||
自动化测试覆盖键盘映射、身份加密及服务器隔离、原生 Peer 的重复启动/取消,以及未授权时不启动信令、授权后自动连接、后台断开/前台重连、服务器切换及首页状态更新。布局测试验证密码弹层、设置导航、密码与连接记录的隔离、已保存密码直接重连及页面重启后的读取、损坏凭据回退输入、密码错误后修改并在成功时保存、悬浮菜单/键盘的横竖屏边界,以及断开确认流程;会话 UI 使用本地测试对象和连接状态回调驱动,不代表真实电脑互通验证。TLS 回归测试在真实 Android 应用进程内使用本机环回 TLS 服务,覆盖受信证书成功、未知 CA 拒绝以及主机名不匹配拒绝;测试证书和私钥仅打包进测试 APK。`verify_apk.py` 检查 APK 必须带有 arm64 原生库、许可证,以及 ELF 和 ZIP 的 16 KB 对齐。
|
||||
|
||||
画面设置与网络统计测试覆盖能力检测、请求/确认/拒绝/超时、快速修改与乱序响应、无效数据、请求编号回绕、码率单位、RTT 平滑、实际提交帧计数、空闲/过期数据和切屏重置;UI 测试检查设置回退及统计更新时面板不重建、不跳动。JNI 测试验证发送失败的反馈与网络/画面采样回调。`test_protocol.py` 在本机编译 Android 实际使用的消息编码函数,并用桌面端共享解析器验证画面设置报文,同时检查 MiniRTC 分项统计的字段映射。
|
||||
|
||||
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 项通过;修复前可稳定复现受信证书握手失败。当时尚未连接真实电脑。
|
||||
|
||||
此前使用 Retroid Pocket 2S(Android 11)通过 P2P 连接 Mac 的延时测试包含 Android 专用解码队列保护。该策略现已撤销,历史延时数据不能代表当前版本;后续统一进行跨平台解码队列优化与性能验证。4K / 60 fps 超过该设备的软件解码能力时仍可能产生积压。声音、触控、屏幕切换、剪贴板完整互通及其他 Android 版本真机仍需进一步验证。
|
||||
|
||||
公告功能新增 12 项测试,覆盖摘要/正文分页、全量未读计数、读取和删除的持久保存、版本更新、服务器及身份隔离、乱序/重复/畸形回复、超时、磁盘写入失败、安全链接和原生信令回调;UI 测试验证角标、详情、下拉刷新、左滑删除、空态和重试。2026-10-01 在 Android 15 arm64 / 16 KB 模拟器上,包含连接、密码、悬浮菜单、画面设置和网络统计的 47 项回归测试通过;APK 对齐检查、原生协议测试及 Lint(零错误)通过。模拟器连接实际默认服务器后,成功加载 1 条公告并显示正确的未读数量;详情及删除操作使用独立测试数据验证。
|
||||
|
||||
远程预览功能新增 11 项测试:默认关闭与授权约束、保存与跨实例读取、服务器隔离、重复连接、清除及删除与未完成截图的竞争、清除失败后重试、损坏图片、居中裁剪,以及开关/清除/撤回授权的页面流程。实际 Surface 截图测试验证 640×360 输出、排除本地覆盖控件、每次连接仅保存一次、首页展示及重启后读取。2026-10-01 在 Android 15 arm64 / 16 KB 模拟器上,58 项预览、公告、连接、密码、布局、画面设置与网络统计回归测试通过。模拟器连接本机 Mac 实测生成 640×360 JPEG 预览,关闭开关后预览目录已清除,原有连接记录和已保存密码保留。
|
||||
|
||||
页面转场新增 7 项测试,覆盖进入/返回的首帧及中间帧位置、正常播放结束、连续导航与系统返回、同页刷新、后台及尺寸变化中断、转场中的输入拦截,以及关闭系统动画后直接切换。会话测试确保视频 Surface 所在页面保持不透明、不建立硬件缓存层,只对其上方的普通页面做淡入淡出,避免视频 Surface 的透明度兼容问题。2026-10-01 在 Android 15 arm64 / 16 KB 模拟器上,包含已有功能的 65 项回归测试通过;转场中间帧截图、Lint(零错误)和 APK 对齐检查通过。
|
||||
|
||||
分段选项动画接入后,页面转场、布局与画面设置的 30 项相关回归测试通过,覆盖设置保存、能力变化、应答排序、拒绝及超时回退。模拟器录屏检查确认鼠标控制、画面偏好的选中底块连续滑动,悬浮控制栏布局保持稳定;Lint 零错误,APK 对齐检查通过。
|
||||
|
||||
## 开源资料
|
||||
|
||||
应用为 GPL-3.0-only,MiniRTC 为 LGPL-3.0-only,其余组件保留各自许可证。`scripts/generate_notices.py` 复用仓库已有的依赖通知,选取 Android 实际使用的组件;`licenses/additional-notices.json` 补充 OpenH264、GNU libiconv 和 NDK / LLVM 运行时通知。APK 的开源信息页提供完整内容。可以修改库源码、重新链接,并使用自己的 Android 密钥签名安装。
|
||||
|
||||
设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。
|
||||
|
||||
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接;打包验证核对每份正文的 SHA-256。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
|
||||
@@ -0,0 +1,41 @@
|
||||
plugins { id 'com.android.application' }
|
||||
|
||||
android {
|
||||
namespace 'cn.crossdesk.mobile'
|
||||
compileSdk 36
|
||||
ndkVersion '28.2.13676358'
|
||||
defaultConfig {
|
||||
applicationId 'cn.crossdesk.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
ndk { abiFilters 'arm64-v8a' }
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
sourceSets.main.jniLibs.srcDirs = [layout.buildDirectory.dir('native')]
|
||||
sourceSets.main.assets.srcDirs += [layout.buildDirectory.dir('generated/notices')]
|
||||
buildTypes.release { minifyEnabled false }
|
||||
packaging.jniLibs.useLegacyPackaging = false
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
}
|
||||
|
||||
tasks.register('buildMiniRtc', Exec) {
|
||||
workingDir rootProject.projectDir
|
||||
commandLine 'python3', 'scripts/build_native.py'
|
||||
environment 'ANDROID_HOME', androidComponents.sdkComponents.sdkDirectory.get().asFile.absolutePath
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn 'buildMiniRtc' }
|
||||
tasks.register('generateNotices', Exec) {
|
||||
workingDir rootProject.projectDir
|
||||
commandLine 'python3', 'scripts/generate_notices.py'
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn 'generateNotices' }
|
||||
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDLTCCAhWgAwIBAgIUAl+BaiMSPm1zRwBr0qnyzpufOaIwDQYJKoZIhvcNAQEL
|
||||
BQAwJjEkMCIGA1UEAwwbQ3Jvc3NEZXNrIGxvY2FsIFRMUyB0ZXN0IENBMB4XDTI2
|
||||
MTAwMTA4MjYyMVoXDTM2MDkyODA4MjYyMVowJjEkMCIGA1UEAwwbQ3Jvc3NEZXNr
|
||||
IGxvY2FsIFRMUyB0ZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
|
||||
AQEAmEAURCIgoeNOGiXcII2v/0SEN8UJ3ItdQrU4A5jOQe2fD12mjOJsQAUQrq/j
|
||||
Bpz3cglAafJlOusgHqYyhFjHn58yZ1I+Z1jPa2CKeyWFYnmFUoL2yyNFmr3YMdkh
|
||||
3caTehZ0+dit6bDYwTvhUwI9CFXLacAZ60tzvnPn+IHmszNNH89mgfr/aJcuXZb/
|
||||
kSiJ/gUiiwp/PJznO9b0kjoJK6eZNoNNcqUyiVsBb0nL2+950GCbwvAA0px3PF/R
|
||||
/qtNstkMPFB6WuMvw/DsxKFDZ8/XbfjJBXi0Rpr+DEW+BOadlTH6Q+CcKxC/YG6q
|
||||
x2oxDGGr1Glz4SYE/OowCS7SaQIDAQABo1MwUTAdBgNVHQ4EFgQU9lut5DDz8k4A
|
||||
MUDIioygb+l+z64wHwYDVR0jBBgwFoAU9lut5DDz8k4AMUDIioygb+l+z64wDwYD
|
||||
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAJauLQRg5mnORh3fjenif
|
||||
nGmHMOlvuBtOFdHGcUYyzkfPuRBp2rCd2mL2rdnJfcyZJAaWQhP9ZZtThbioczTj
|
||||
Kf8UUTv2WYLKIWcZ1jyEEmukBhYxRpUfn4FqQG1yCqmF112coSCCY+7aGjl/Tcnx
|
||||
E5SKS/FhARnXwObBaiG4sZoOQ4fbC70I94xfY8uSu6h9uVnnZS05tVbUD2gATn57
|
||||
1snZUoJYTHi15QPZM3H/UViSYq2Inkw9yOcV3FRs6f8klePGVmUUDvT+I48jGjSz
|
||||
cg2zgNErBYvLEJ8FxHX5oxJZzVZAuVxugYu1Ru5kEl1XO1GRHRf4/8JIl1nyat0a
|
||||
hQ==
|
||||
-----END CERTIFICATE-----
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,119 @@
|
||||
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.*;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class AnnouncementInboxTest {
|
||||
private AnnouncementInbox inbox;
|
||||
private File directory;
|
||||
private final ArrayDeque<JSONObject> sent=new ArrayDeque<>();
|
||||
private interface Checked { void run() throws Exception; }
|
||||
private void main(Checked action)throws Exception {
|
||||
FutureTask<Void> task=new FutureTask<>(()->{action.run();return null;});
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(task);task.get();
|
||||
}
|
||||
@Before public void setup()throws Exception {
|
||||
directory=Files.createTempDirectory(InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir().toPath(),"announcements-").toFile();
|
||||
main(()->{inbox=new AnnouncementInbox(directory);inbox.sender=(message,id)->sent.add(message);inbox.configure("Example.test.",9099,"123456789");inbox.setConnected(true);});
|
||||
}
|
||||
@After public void cleanup()throws Exception {
|
||||
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 {
|
||||
int offset=request.getInt("offset");boolean summary=request.getBoolean("summary_only");JSONArray items=new JSONArray();
|
||||
for(int i=offset;i<Math.min(count,offset+(summary?200:20));i++){
|
||||
JSONObject item=new JSONObject().put("id",i+1).put("revision",revision);
|
||||
if(!summary)item.put("title",i==0?"欢迎使用 CrossDesk":"公告 "+(i+1)).put("body","安卓公告与 iOS 保持一致。\n\n访问 [CrossDesk 官网](https://crossdesk.cn) 了解更多。\n支持中文、换行与网页链接。").put("updated_at",1790812800L);
|
||||
items.put(item);
|
||||
}
|
||||
return new JSONObject().put("type","announcements").put("request_id",request.getString("request_id"))
|
||||
.put("summary_only",summary).put("offset",offset).put("total",count).put("catalog_revision",revision).put("items",items);
|
||||
}
|
||||
private void drain(int count,long revision)throws Exception {
|
||||
int limit=100;while(!sent.isEmpty()){assertTrue("Request loop",limit-->0);inbox.receive(answer(sent.remove(),count,revision));}
|
||||
}
|
||||
@Test public void summariesCountAllUnreadAndBodiesPageInTwenties()throws Exception {main(()->{
|
||||
JSONObject first=sent.remove();inbox.receive(answer(first,203,1));assertEquals(200,sent.peek().getInt("offset"));assertEquals(0,inbox.items.size());
|
||||
inbox.receive(answer(sent.remove(),203,1));assertEquals(203,inbox.unread);assertFalse(sent.peek().getBoolean("summary_only"));
|
||||
drain(203,1);assertEquals(20,inbox.items.size());assertEquals(203,inbox.total);assertFalse(inbox.loading);assertTrue(inbox.loaded);
|
||||
inbox.loadMore();assertEquals(20,sent.peek().getInt("offset"));drain(203,1);assertEquals(40,inbox.items.size());
|
||||
});}
|
||||
@Test public void readAndDeletePersistPerRevisionAndScope()throws Exception {main(()->{
|
||||
drain(3,1);inbox.markRead(inbox.items.get(0));inbox.dismiss(inbox.items.get(1));assertEquals(1,inbox.unread);assertEquals(2,inbox.total);
|
||||
inbox.close();inbox=new AnnouncementInbox(directory);inbox.sender=(message,id)->sent.add(message);
|
||||
inbox.configure("example.TEST",9099,"123456789");inbox.setConnected(true);drain(3,1);
|
||||
assertEquals(2,inbox.items.size());assertTrue(inbox.items.get(0).read);assertEquals(1,inbox.unread);
|
||||
inbox.refresh();drain(3,2);assertEquals(3,inbox.total);assertEquals(3,inbox.unread);assertEquals(3,inbox.items.size());
|
||||
inbox.configure("other.test",9099,"123456789");drain(3,1);assertEquals(3,inbox.unread);
|
||||
inbox.configure("example.test",9099,"different-device");drain(3,1);assertEquals(3,inbox.unread);
|
||||
inbox.configure("example.test",9999,"123456789");drain(3,1);assertEquals(3,inbox.unread);
|
||||
inbox.configure("example.test",9099,"123456789");drain(3,1);assertEquals(1,inbox.unread);
|
||||
});}
|
||||
@Test public void deletingAPageLoadsReplacementAndStaleConfirmationDoesNotDeleteRevision()throws Exception {main(()->{
|
||||
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());
|
||||
inbox.refresh();drain(2,2);assertFalse(inbox.failed);
|
||||
inbox.refresh();JSONObject summary=answer(sent.remove(),2,3);summary.getJSONArray("items").getJSONObject(1).put("id",1);inbox.receive(summary);
|
||||
assertTrue(inbox.failed);inbox.refresh();drain(2,3);assertFalse(inbox.failed);
|
||||
inbox.refresh();inbox.receive(answer(sent.remove(),2,4));JSONObject body=answer(sent.remove(),2,4);
|
||||
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="<b>普通文本</b>\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("<b>普通文本</b>\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://<bad>"})assertFalse(bad,AnnouncementText.isWebURL(bad));
|
||||
assertTrue(AnnouncementText.isWebURL("HTTPS://example.com/path"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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<JSONObject>();var failed=new java.util.concurrent.atomic.AtomicReference<String>();
|
||||
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<JSONObject>();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<RemoteNetworkStatistics.Snapshot>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
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<String,?> 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<String,?> 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<Void> 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<group.getChildCount();i++){View match=find(group.getChildAt(i),label);if(match!=null)return match;}}return null;
|
||||
}
|
||||
private void click(String label)throws Exception{main(()->{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<Intent> 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<list.getAdapter().getCount();i++){String chunk=(String)list.getAdapter().getItem(i);assertTrue(chunk.length()<=4000);actual.append(chunk);}
|
||||
assertEquals("The complete license must be available offline",expected,actual.toString());list.setSelection(list.getAdapter().getCount()-1);
|
||||
});
|
||||
main(()->{
|
||||
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();i++){Thread.sleep(100);instrumentation.waitForIdleSync();}
|
||||
assertTrue(overlay.getWidth()>overlay.getHeight());main(overlay::constrainPanels);snapshot("layout-session-landscape");
|
||||
main(()->{for(int i=0;i<overlay.getChildCount();i++){View child=overlay.getChildAt(i);if(child.getVisibility()!=View.VISIBLE)continue;assertTrue(child.getX()>=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<java.util.function.Consumer<byte[]>>();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<org.json.JSONObject> 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<org.json.JSONObject> 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<group.getChildCount();i++){var image=findPreviewImage(group.getChildAt(i));if(image!=null)return image;}}return null;}
|
||||
private Switch rememberSwitch(View view){
|
||||
if(view instanceof Switch)return (Switch)view;
|
||||
if(view instanceof ViewGroup){ViewGroup group=(ViewGroup)view;for(int i=0;i<group.getChildCount();i++){Switch match=rememberSwitch(group.getChildAt(i));if(match!=null)return match;}}return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 channelRatesRouteAndEncryptionComeFromTransport()throws Exception{
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();assertNull(state.snapshot(10).report);assertEquals("—",state.snapshot(10).mode());
|
||||
state.receive(new RemoteNetworkStatistics.Report(report(1,true,20)),10);var snapshot=state.snapshot(10);
|
||||
assertEquals(8000000,snapshot.report.traffic[0].inbound);assertEquals(64000,snapshot.report.traffic[1].inbound);assertEquals(3000,snapshot.report.traffic[2].outbound);assertEquals(8066000,snapshot.report.traffic[3].inbound);assertEquals(.06,snapshot.report.traffic[3].loss,.0001);assertEquals("TURN 中继",snapshot.mode());assertTrue(snapshot.report.srtp);
|
||||
state.receive(new RemoteNetworkStatistics.Report(report(0,false,20)),11);assertEquals("P2P 直连",state.snapshot(11).mode());assertFalse(state.snapshot(11).report.srtp);
|
||||
}
|
||||
@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));
|
||||
}
|
||||
@Test public void displaySwitchResetsVideoWithoutLosingTraffic()throws Exception{
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.receive(new RemoteNetworkStatistics.Report(report(1,true,20)),10);state.frame(1,100,1920,1080,10);state.resetVideo();state.frame(1,100,1920,1080,10.1);
|
||||
assertEquals(0,state.snapshot(10.1).fps);assertEquals("—",state.snapshot(10.1).resolution());assertNotNull(state.snapshot(10.1).report);
|
||||
state.frame(2,-1,1280,720,10.2);state.frame(3,Double.NaN,1280,720,10.3);state.frame(4,Double.POSITIVE_INFINITY,1280,720,10.4);assertEquals(3,state.snapshot(10.5).fps);assertTrue(Double.isNaN(state.snapshot(10.5).latency));
|
||||
state.frame(5,50,1280,720,10.6);assertEquals(50,state.snapshot(10.6).latency,0);assertEquals("1280 × 720",state.snapshot(10.6).resolution());
|
||||
}
|
||||
@Test public void slowFramesKeepTheirMeasuredLatencyInsteadOfBecomingUnknown(){
|
||||
RemoteNetworkStatistics state=new RemoteNetworkStatistics();state.frame(1,6000,1920,1080,10);state.frame(2,8000,1920,1080,10.2);
|
||||
assertEquals(7000,state.snapshot(10.3).latency,0);assertEquals("7000 ms",RemoteNetworkStatistics.latency(state.snapshot(10.3).latency));
|
||||
state.frame(3,50,1920,1080,11.3);assertEquals(50,state.snapshot(11.3).latency,0);
|
||||
}
|
||||
@Test public void formatMatchesIosAndUnknownIsNotZero(){
|
||||
assertEquals("—",RemoteNetworkStatistics.bitrate(-1));assertEquals("0 bps",RemoteNetworkStatistics.bitrate(0));assertEquals("64 Kbps",RemoteNetworkStatistics.bitrate(64000));assertEquals("8.0 Mbps",RemoteNetworkStatistics.bitrate(8000000));
|
||||
assertEquals("<1 ms",RemoteNetworkStatistics.latency(0));assertEquals("—",RemoteNetworkStatistics.latency(Double.NaN));assertEquals("0.5%",RemoteNetworkStatistics.loss(.005));assertEquals("120.0%",RemoteNetworkStatistics.loss(1.2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.app.Instrumentation;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.*;
|
||||
import org.junit.runner.RunWith;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class PageNavigationTest {
|
||||
private final Instrumentation instrumentation=InstrumentationRegistry.getInstrumentation();
|
||||
private MainActivity activity;
|
||||
private SharedPreferences settings;
|
||||
private Map<String,?> 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<Void> 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<group.getChildCount();i++){View match=find(group.getChildAt(i),label);if(match!=null)return match;}}return null;
|
||||
}
|
||||
private void click(String label){View item=find(page(),label);assertNotNull(label,item);assertTrue(item.performClick());}
|
||||
private ValueAnimator animator(){return (ValueAnimator)field(host,"animator");}
|
||||
// Stop at a real intermediate frame; no sleeps or dependence on emulator rendering speed.
|
||||
private void transition(Runnable navigation,long time)throws Exception{
|
||||
FutureTask<Void> 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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<Bitmap>();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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class RemoteVersionCheckTest {
|
||||
private static byte[] json(String value) { return value.getBytes(StandardCharsets.UTF_8); }
|
||||
private static JSONObject host(Object version) {
|
||||
try { return new JSONObject().put("app_version", version); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
private static final class Fetches implements RemoteVersionCheck.Fetcher {
|
||||
final List<Consumer<byte[]>> replies = new ArrayList<>();
|
||||
int cancellations;
|
||||
public RemoteVersionCheck.Cancellation fetch(Consumer<byte[]> reply) {
|
||||
replies.add(reply);
|
||||
return () -> cancellations++;
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void sharedNativeComparisonMatchesIosReleaseOrdering() {
|
||||
byte[] release = json("{\"latest_version\":\"v1.5.3-20260928\",\"patch\":2}");
|
||||
for (String version : new String[]{"1.5.2", "v1.5.3-20260928", "1.5.3-1-20260929", "1.5.3-20260928-1"}) {
|
||||
assertTrue(NativeSession.nValidAppVersion(version));
|
||||
assertTrue(version, NativeSession.nHasAppUpdate(version, release));
|
||||
}
|
||||
for (String version : new String[]{"1.5.3-2-20260927", "1.5.3-3-20260928", "1.6.0"})
|
||||
assertFalse(version, NativeSession.nHasAppUpdate(version, release));
|
||||
assertFalse(NativeSession.nHasAppUpdate("1.5.3-20260927", json("{\"version\":\"1.5.3-20261001\"}")));
|
||||
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};
|
||||
RemoteVersionCheck check = new RemoteVersionCheck(fetches, () -> notices[0]++);
|
||||
check.hostInfo(host(version)); assertEquals(0, notices[0]);
|
||||
check.connected(); assertEquals(1, notices[0]);
|
||||
check.hostInfo(host(version)); check.connected(); assertEquals(1, notices[0]);
|
||||
assertTrue(fetches.replies.isEmpty()); check.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@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]++);
|
||||
check.hostInfo(host("1.0.0")); check.connected(); check.reset();
|
||||
assertEquals(1, fetches.cancellations);
|
||||
fetches.replies.get(0).accept(json("{\"version\":\"2.0.0\"}")); assertEquals(0, notices[0]);
|
||||
check.connected(); check.hostInfo(host("3.0.0"));
|
||||
fetches.replies.get(0).accept(json("{\"version\":\"2.0.0\"}"));
|
||||
fetches.replies.get(1).accept(json("{\"version\":\"2.0.0\"}")); assertEquals(0, notices[0]);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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<String,?> 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<Void> 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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
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.io.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.net.InetAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.security.KeyStore;
|
||||
import java.util.concurrent.*;
|
||||
import javax.net.ssl.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/** Exercises the actual MiniRTC TLS path inside an Android app process (AT_SECURE=1).
|
||||
* The bundled PKCS12 keys are public test fixtures, only served on loopback. No
|
||||
* websocket upgrade/login or external server is involved in these tests. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class TlsTrustTest {
|
||||
@Test public void explicitlyTrustedCertificateCompletesHandshake() throws Exception {
|
||||
assertTrue("The app must load the supplied trust bundle despite AT_SECURE", handshake("valid",true));
|
||||
}
|
||||
@Test public void untrustedCertificateIsRejected() throws Exception {
|
||||
assertFalse("A test CA absent from the system store must not be trusted",handshake("valid",false));
|
||||
}
|
||||
@Test public void certificateForAnotherHostIsRejected() throws Exception {
|
||||
assertFalse("Trusting the CA must not bypass hostname verification",handshake("wrong-host",true));
|
||||
}
|
||||
private boolean handshake(String certificate,boolean trustFixture) throws Exception {
|
||||
var instrumentation=InstrumentationRegistry.getInstrumentation();Context context=instrumentation.getTargetContext();
|
||||
KeyStore keyStore=KeyStore.getInstance("PKCS12");char[] password="local-test-only".toCharArray();
|
||||
try(InputStream input=instrumentation.getContext().getAssets().open("tls/"+certificate+".p12")){keyStore.load(input,password);}
|
||||
KeyManagerFactory keys=KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());keys.init(keyStore,password);
|
||||
SSLContext tls=SSLContext.getInstance("TLS");tls.init(keys.getKeyManagers(),null,null);
|
||||
ExecutorService serverWorker=Executors.newSingleThreadExecutor();
|
||||
NativeSession session=new NativeSession(context,"127.0.0.1",1,"unused","unused",new NativeSession.Listener(){
|
||||
public void status(String value){} public void connected(){fail("Test server never opens a desktop session");}
|
||||
public void ended(String value){} public void data(JSONObject value){}public void videoSize(int w,int h){}
|
||||
public void clipboard(String value){}public void statistics(RemoteNetworkStatistics.Snapshot value){}
|
||||
});
|
||||
File roots=new File(context.getCacheDir(),"tls-test-roots.pem");
|
||||
Field rtcField=NativeSession.class.getDeclaredField("RTC");rtcField.setAccessible(true);ExecutorService rtc=(ExecutorService)rtcField.get(null);
|
||||
try(SSLServerSocket server=(SSLServerSocket)tls.getServerSocketFactory().createServerSocket(0,1,InetAddress.getByName("127.0.0.1"))){
|
||||
server.setSoTimeout(10000);
|
||||
if(trustFixture){try(InputStream input=instrumentation.getContext().getAssets().open("tls/root.pem")){Files.copy(input,roots.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);}}
|
||||
else{Method export=NativeSession.class.getDeclaredMethod("exportTrust");export.setAccessible(true);File systemRoots=(File)export.invoke(session);Files.copy(systemRoots.toPath(),roots.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);}
|
||||
Future<Boolean> request=serverWorker.submit(()->{
|
||||
try(SSLSocket socket=(SSLSocket)server.accept()){
|
||||
socket.setSoTimeout(10000);socket.startHandshake();
|
||||
String first=new BufferedReader(new InputStreamReader(socket.getInputStream(),java.nio.charset.StandardCharsets.US_ASCII)).readLine();
|
||||
return first!=null&&first.startsWith("GET ");
|
||||
}catch(SSLException expected){return false;}
|
||||
});
|
||||
Method create=NativeSession.class.getDeclaredMethod("nCreate",NativeSession.class,String.class,int.class,String.class,String.class,String.class);create.setAccessible(true);
|
||||
Field handle=NativeSession.class.getDeclaredField("handle");handle.setAccessible(true);
|
||||
rtc.submit(()->{try{long value=(long)create.invoke(null,session,"127.0.0.1",server.getLocalPort(),"",context.getCacheDir().getAbsolutePath(),roots.getAbsolutePath());handle.setLong(session,value);assertNotEquals(0L,value);}catch(ReflectiveOperationException error){throw new RuntimeException(error);}}).get(10,TimeUnit.SECONDS);
|
||||
return request.get(15,TimeUnit.SECONDS);
|
||||
}finally{session.close();rtc.submit(()->{}).get(10,TimeUnit.SECONDS);serverWorker.shutdownNow();roots.delete();}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<application android:theme="@style/AppTheme" android:label="CrossDesk"
|
||||
android:icon="@mipmap/ic_launcher" android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:supportsRtl="true" android:usesCleartextTraffic="false">
|
||||
<activity android:name=".MainActivity" android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,116 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import java.util.List;
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Mirrors the iOS About / component / source / document navigation. */
|
||||
final class AboutPages {
|
||||
private final MainActivity activity;
|
||||
private final MobileUi ui;
|
||||
private final Runnable settings;
|
||||
private final LicenseCatalog catalog;
|
||||
|
||||
AboutPages(MainActivity activity,MobileUi ui,Runnable settings){
|
||||
this.activity=activity;this.ui=ui;this.settings=settings;
|
||||
LicenseCatalog loaded;try{loaded=new LicenseCatalog(activity);}catch(Exception error){loaded=null;}catalog=loaded;
|
||||
}
|
||||
void show(){
|
||||
LinearLayout content=activity.formPage("about","关于 CrossDesk",settings);
|
||||
if(catalog==null){unavailable(content);return;}
|
||||
LinearLayout version=activity.section(content,"");value(version,"版本",applicationVersion());ui.gap(content,28);
|
||||
LinearLayout links=activity.section(content,"");
|
||||
activity.link(links,"软件许可","",()->component(catalog.find("crossdesk"),this::show));ui.divider(links);
|
||||
activity.link(links,"开源组件","",this::components);ui.divider(links);
|
||||
activity.link(links,"源码与构建说明","",this::source);
|
||||
activity.footer(content,"许可与版权声明可离线查阅。");
|
||||
}
|
||||
private String applicationVersion(){
|
||||
try{return activity.getPackageManager().getPackageInfo(activity.getPackageName(),0).versionName;}catch(android.content.pm.PackageManager.NameNotFoundException error){return "—";}
|
||||
}
|
||||
private void components(){
|
||||
LinearLayout content=activity.formPage("open-source","开源组件",this::show);
|
||||
LinearLayout notice=activity.section(content,"OpenFEC 告知");
|
||||
paragraph(notice,"本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。",SECONDARY);
|
||||
ui.divider(notice);paragraph(notice,"(c) Copyright 2009 - 2012 INRIA - All rights reserved",INK);ui.divider(notice);
|
||||
activity.link(notice,"OpenFEC 许可与源码","",()->component(catalog.find("openfec"),this::components));ui.gap(content,28);
|
||||
LinearLayout list=activity.section(content,"");boolean first=true;
|
||||
for(LicenseCatalog.Component item:catalog.components){
|
||||
if(item.id.equals("crossdesk")||item.id.equals("openfec"))continue;
|
||||
if(!first)ui.divider(list);first=false;
|
||||
LinearLayout row=ui.row();row.setPadding(ui.dp(16),ui.dp(12),ui.dp(12),ui.dp(12));
|
||||
LinearLayout labels=ui.column();labels.addView(ui.text(item.name,16,false));ui.gap(labels,4);
|
||||
TextView license=ui.text(item.license,12,false);license.setTextColor(SECONDARY);labels.addView(license);
|
||||
row.addView(labels,new LinearLayout.LayoutParams(0,-2,1));TextView chevron=ui.text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);row.addView(chevron);
|
||||
row.setContentDescription(item.name+","+item.license);row.setClickable(true);row.setFocusable(true);row.setOnClickListener(v->component(item,this::components));list.addView(row);
|
||||
}
|
||||
activity.footer(content,"各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。");
|
||||
}
|
||||
private void component(LicenseCatalog.Component item,Runnable back){
|
||||
LinearLayout content=activity.formPage("license:"+item.id,item.name,back);LinearLayout info=activity.section(content,"");
|
||||
String version=item.id.equals("crossdesk")?applicationVersion():item.version.length()==40?item.version.substring(0,12):item.version;
|
||||
value(info,"版本",version);ui.divider(info);value(info,"许可",item.license);ui.divider(info);
|
||||
external(info,"查看源码",item.sourceURL);
|
||||
if(!item.buildDocuments.isEmpty()||!item.buildSourceURL.isEmpty()){
|
||||
ui.divider(info);
|
||||
if(!item.buildDocuments.isEmpty())activity.link(info,"构建配方与项目补丁","",()->buildSources(item,back));
|
||||
else external(info,"构建配方与项目补丁",item.buildSourceURL);
|
||||
}
|
||||
ui.gap(content,16);LinearLayout docs=activity.section(content,"许可与版权声明");
|
||||
for(int i=0;i<item.documents.size();i++){
|
||||
if(i>0)ui.divider(docs);LicenseCatalog.Document document=item.documents.get(i);
|
||||
activity.link(docs,item.documentTitle(document),"",()->document(item.documentTitle(document),document,()->component(item,back)));
|
||||
}
|
||||
}
|
||||
private void source(){
|
||||
LinearLayout content=activity.formPage("source","源码与构建说明",this::show);
|
||||
LinearLayout info=activity.section(content,"本版本源码与构建说明");
|
||||
if(catalog.source==null){paragraph(info,"本版本源码信息暂不可用。",SECONDARY);ui.divider(info);external(info,"项目主页","https://github.com/kunkundi/crossdesk");return;}
|
||||
String revision=catalog.source.optString("revision","");String tag=catalog.source.isNull("tag")?"":catalog.source.optString("tag","");
|
||||
value(info,"源码版本",tag.isEmpty()?revision.substring(0,Math.min(12,revision.length())):tag);ui.divider(info);
|
||||
if(catalog.source.optBoolean("isModified")){paragraph(info,"开发版本包含尚未发布的修改,以下链接为基础版本源码。",SECONDARY);ui.divider(info);}
|
||||
external(info,"查看源码",catalog.source.optString("sourceURL",catalog.find("crossdesk").sourceURL));ui.divider(info);
|
||||
try{
|
||||
List<LicenseCatalog.Document> documents=LicenseCatalog.documents(catalog.source.optJSONArray("buildDocuments"));
|
||||
if(!documents.isEmpty())actionLink(info,"构建说明",()->document("构建说明",documents.get(0),this::source));
|
||||
else external(info,"构建说明",catalog.source.optString("buildInstructionsURL","https://github.com/kunkundi/crossdesk"));
|
||||
}catch(org.json.JSONException error){paragraph(info,"本版本源码信息暂不可用。",SECONDARY);}
|
||||
}
|
||||
private void buildSources(LicenseCatalog.Component item,Runnable back){
|
||||
LinearLayout content=activity.formPage("build:"+item.id,"构建配方与项目补丁",()->component(item,back));
|
||||
if(!item.buildSourceURL.isEmpty()){LinearLayout upstream=activity.section(content,"");external(upstream,"查看源码",item.buildSourceURL);ui.gap(content,28);}
|
||||
LinearLayout docs=activity.section(content,"");
|
||||
for(int i=0;i<item.buildDocuments.size();i++){
|
||||
if(i>0)ui.divider(docs);LicenseCatalog.Document document=item.buildDocuments.get(i);
|
||||
activity.link(docs,document.title,"",()->document(document.title,document,()->buildSources(item,back)));
|
||||
}
|
||||
}
|
||||
private void document(String title,LicenseCatalog.Document document,Runnable back){
|
||||
try{activity.licenseDocument(title,LicenseCatalog.read(activity,document.asset),back);}catch(java.io.IOException error){unavailable(activity.formPage("license-error",title,back));}
|
||||
}
|
||||
private void value(LinearLayout parent,String label,String value){
|
||||
LinearLayout row=ui.row();row.setPadding(ui.dp(16),ui.dp(14),ui.dp(16),ui.dp(14));
|
||||
TextView key=ui.text(label,16,false);key.setPadding(0,0,ui.dp(16),0);row.addView(key);
|
||||
TextView content=ui.text(value,16,false);content.setTextColor(SECONDARY);content.setGravity(Gravity.END);content.setTextIsSelectable(true);row.addView(content,new LinearLayout.LayoutParams(0,-2,1));parent.addView(row);
|
||||
}
|
||||
private void paragraph(LinearLayout parent,String text,int color){TextView view=ui.text(text,13,false);view.setTextColor(color);view.setPadding(ui.dp(16),ui.dp(12),ui.dp(16),ui.dp(12));view.setLineSpacing(ui.dp(3),1);parent.addView(view);}
|
||||
private void external(LinearLayout parent,String title,String url){
|
||||
actionLink(parent,title,()->open(url));
|
||||
}
|
||||
private void actionLink(LinearLayout parent,String title,Runnable action){TextView link=ui.action(title,BLUE,action);link.setTypeface(android.graphics.Typeface.DEFAULT);link.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);link.setPadding(ui.dp(16),ui.dp(12),ui.dp(16),ui.dp(12));parent.addView(link);}
|
||||
private void open(String url){
|
||||
Uri uri=Uri.parse(url);if(!"https".equals(uri.getScheme())&&!"http".equals(uri.getScheme()))return;
|
||||
try{activity.startActivity(new Intent(Intent.ACTION_VIEW,uri).addCategory(Intent.CATEGORY_BROWSABLE));}catch(ActivityNotFoundException error){Toast.makeText(activity,"未找到可打开链接的浏览器",Toast.LENGTH_SHORT).show();}
|
||||
}
|
||||
private void unavailable(LinearLayout content){
|
||||
content.setGravity(Gravity.CENTER);ui.gap(content,32);TextView icon=ui.text("▤",36,false);icon.setTextColor(SECONDARY);content.addView(icon);ui.gap(content,12);
|
||||
content.addView(ui.text("无法读取开源许可",17,true));TextView message=ui.text("应用中的许可文件缺失或损坏,请重新安装应用。",14,false);message.setGravity(Gravity.CENTER);message.setTextColor(SECONDARY);content.addView(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.AtomicFile;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** Main-thread inbox. The server owns content; this device owns read/dismissed revisions. */
|
||||
final class AnnouncementInbox implements AutoCloseable {
|
||||
interface Sender { void send(JSONObject request, String requestId); }
|
||||
static final class Item {
|
||||
final long id, revision, updatedAt;
|
||||
final String title, body;
|
||||
boolean read;
|
||||
Item(JSONObject json) throws Exception {
|
||||
id=integer(json,"id");revision=integer(json,"revision");updatedAt=integer(json,"updated_at");
|
||||
title=string(json,"title");body=string(json,"body");
|
||||
if(id<=0||revision<=0||updatedAt<=0||updatedAt>32_503_680_000L||
|
||||
title.getBytes(StandardCharsets.UTF_8).length>240||body.getBytes(StandardCharsets.UTF_8).length>8000)
|
||||
throw new IllegalArgumentException("Invalid announcement");
|
||||
}
|
||||
}
|
||||
final List<Item> items=new ArrayList<>();
|
||||
int total, unread;
|
||||
boolean loaded, loading, failed, readSaveFailed, deleteFailed;
|
||||
Sender sender;
|
||||
Runnable changed=()->{};
|
||||
private final File directory;
|
||||
private final Handler handler=new Handler(Looper.getMainLooper());
|
||||
private final Map<Long,Long> reads=new HashMap<>(), dismissed=new HashMap<>();
|
||||
private Map<Long,Long> catalog=new LinkedHashMap<>(), pendingCatalog=new LinkedHashMap<>();
|
||||
private final Map<Integer,List<Long>> pageIds=new HashMap<>();
|
||||
private final Map<Integer,List<Item>> pageBodies=new HashMap<>();
|
||||
private long catalogRevision=-1, pendingRevision=-1;
|
||||
private int pendingTotal, pageOffset, visibleLimit=20;
|
||||
private boolean summary=true, connected;
|
||||
private String scope="", requestId;
|
||||
private AtomicFile file;
|
||||
private Runnable timeout;
|
||||
|
||||
AnnouncementInbox(File directory){this.directory=directory;}
|
||||
boolean isConnected(){return connected;}
|
||||
void configure(String host,int port,String deviceId){
|
||||
if(host.isEmpty()||port<=0||deviceId.isEmpty())return;
|
||||
String canonical=host.toLowerCase(Locale.ROOT);
|
||||
if(canonical.endsWith("."))canonical=canonical.substring(0,canonical.length()-1);
|
||||
String next=new JSONArray().put(canonical).put(port).put(deviceId).toString();
|
||||
if(scope.equals(next))return;
|
||||
boolean wasConnected=connected;reset();connected=wasConnected;scope=next;
|
||||
try{
|
||||
byte[] hash=MessageDigest.getInstance("SHA-256").digest(scope.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder name=new StringBuilder();for(byte b:hash)name.append(String.format(Locale.ROOT,"%02x",b&255));
|
||||
file=new AtomicFile(new File(directory,name+".json"));loadLocalState();
|
||||
}catch(Exception error){file=null;}
|
||||
refresh();
|
||||
}
|
||||
void reset(){
|
||||
cancelRequest();scope="";file=null;connected=false;items.clear();reads.clear();dismissed.clear();
|
||||
catalog.clear();pendingCatalog.clear();pageIds.clear();pageBodies.clear();
|
||||
catalogRevision=pendingRevision=-1;pendingTotal=pageOffset=total=unread=0;visibleLimit=20;
|
||||
loaded=failed=readSaveFailed=deleteFailed=false;changed.run();
|
||||
}
|
||||
void setConnected(boolean value){
|
||||
if(connected==value)return;connected=value;
|
||||
if(value)refresh();else{cancelRequest();changed.run();}
|
||||
}
|
||||
void refresh(){
|
||||
cancelRequest();pendingCatalog=new LinkedHashMap<>();pendingRevision=-1;pendingTotal=0;summary=true;
|
||||
request(0);changed.run();
|
||||
}
|
||||
void loadMore(){
|
||||
if(!connected||loading||failed||catalogRevision<0||visibleLimit>=total)return;
|
||||
visibleLimit+=Math.min(20,total-visibleLimit);rebuild();changed.run();
|
||||
}
|
||||
Item find(long id,long revision){
|
||||
for(Item item:items)if(item.id==id&&item.revision==revision)return item;return null;
|
||||
}
|
||||
void markRead(Item item){
|
||||
if(find(item.id,item.revision)==null||Objects.equals(reads.get(item.id),item.revision))return;
|
||||
Map<Long,Long> updated=new HashMap<>(reads);updated.put(item.id,item.revision);
|
||||
if(!saveLocalState(updated,dismissed)){readSaveFailed=true;changed.run();return;}
|
||||
reads.clear();reads.putAll(updated);readSaveFailed=deleteFailed=false;updateReadingState();changed.run();
|
||||
}
|
||||
void dismiss(Item item){
|
||||
if(find(item.id,item.revision)==null){deleteFailed=true;changed.run();return;}
|
||||
Map<Long,Long> updated=new HashMap<>(dismissed);updated.put(item.id,item.revision);
|
||||
if(!saveLocalState(reads,updated)){deleteFailed=true;changed.run();return;}
|
||||
dismissed.clear();dismissed.putAll(updated);deleteFailed=readSaveFailed=false;
|
||||
if(summary&&loading){items.removeIf(this::isDismissed);updateReadingState();}
|
||||
else{cancelRequest();rebuild();}
|
||||
changed.run();
|
||||
}
|
||||
void failed(String id){
|
||||
if(!Objects.equals(requestId,id)||id==null)return;
|
||||
cancelRequest();failed=true;changed.run();
|
||||
}
|
||||
void receive(JSONObject message){
|
||||
Object identifier=message.opt("request_id");
|
||||
if(!(identifier instanceof String)||!identifier.equals(requestId))return;
|
||||
String id=(String)identifier;
|
||||
try{
|
||||
if(!"announcements".equals(string(message,"type"))||message.has("error"))throw new IllegalArgumentException();
|
||||
Object summaryFlag=message.get("summary_only");
|
||||
long offsetValue=integer(message,"offset"), totalValue=integer(message,"total");
|
||||
long revision=integer(message,"catalog_revision");JSONArray entries=message.getJSONArray("items");
|
||||
if(!(summaryFlag instanceof Boolean)||((Boolean)summaryFlag)!=summary||offsetValue<0||
|
||||
totalValue<offsetValue||totalValue>Integer.MAX_VALUE||revision<0||
|
||||
entries.length()!=Math.min(summary?200:20,totalValue-offsetValue))throw new IllegalArgumentException();
|
||||
int offset=(int)offsetValue, count=(int)totalValue;
|
||||
LinkedHashMap<Long,Long> entriesById=new LinkedHashMap<>();
|
||||
for(int i=0;i<entries.length();i++){
|
||||
JSONObject entry=entries.getJSONObject(i);long key=integer(entry,"id"), version=integer(entry,"revision");
|
||||
if(key<=0||version<=0||entriesById.put(key,version)!=null)throw new IllegalArgumentException();
|
||||
}
|
||||
long expected=summary?pendingRevision:catalogRevision;
|
||||
if(expected>=0&&expected!=revision){refresh();return;}
|
||||
if(summary){
|
||||
if(offset!=pendingCatalog.size()||(pendingRevision>=0&&pendingTotal!=count))throw new IllegalArgumentException();
|
||||
for(Map.Entry<Long,Long> entry:entriesById.entrySet())
|
||||
if(pendingCatalog.put(entry.getKey(),entry.getValue())!=null)throw new IllegalArgumentException();
|
||||
pendingRevision=revision;pendingTotal=count;cancelRequest();
|
||||
if(pendingCatalog.size()<pendingTotal){request(pendingCatalog.size());changed.run();return;}
|
||||
if(catalogRevision!=pendingRevision){pageIds.clear();pageBodies.clear();}
|
||||
catalog=new LinkedHashMap<>(pendingCatalog);catalogRevision=pendingRevision;
|
||||
reads.keySet().retainAll(catalog.keySet());dismissed.keySet().retainAll(catalog.keySet());
|
||||
}else{
|
||||
if(offset!=pageOffset||count!=catalog.size())throw new IllegalArgumentException();
|
||||
List<Item> bodies=new ArrayList<>();
|
||||
for(int i=0;i<entries.length();i++){
|
||||
Item item=new Item(entries.getJSONObject(i));
|
||||
if(!Objects.equals(catalog.get(item.id),item.revision))throw new IllegalArgumentException();
|
||||
// A repeated ID on another body page must not create duplicate rows.
|
||||
for(Map.Entry<Integer,List<Long>> page:pageIds.entrySet())
|
||||
if(page.getKey()!=offset&&page.getValue().contains(item.id))throw new IllegalArgumentException();
|
||||
bodies.add(item);
|
||||
}
|
||||
cancelRequest();pageIds.put(offset,new ArrayList<>(entriesById.keySet()));pageBodies.put(offset,bodies);
|
||||
}
|
||||
failed=false;rebuild();changed.run();
|
||||
}catch(Exception error){failed(id);}
|
||||
}
|
||||
private void request(int offset){
|
||||
if(!connected||scope.isEmpty())return;
|
||||
String id=UUID.randomUUID().toString();
|
||||
try{
|
||||
JSONObject payload=new JSONObject().put("type","announcements_list").put("request_id",id)
|
||||
.put("summary_only",summary).put("offset",offset);
|
||||
requestId=id;loading=true;failed=false;timeout=()->failed(id);handler.postDelayed(timeout,12_000);
|
||||
if(sender!=null)sender.send(payload,id);else failed(id);
|
||||
}catch(Exception error){failed(id);}
|
||||
}
|
||||
private void cancelRequest(){
|
||||
if(timeout!=null)handler.removeCallbacks(timeout);timeout=null;requestId=null;loading=false;
|
||||
}
|
||||
private boolean isDismissed(Item item){return Objects.equals(dismissed.get(item.id),item.revision);}
|
||||
private void updateReadingState(){
|
||||
total=unread=0;
|
||||
for(Map.Entry<Long,Long> entry:catalog.entrySet()){
|
||||
if(Objects.equals(dismissed.get(entry.getKey()),entry.getValue()))continue;
|
||||
total++;if(!Objects.equals(reads.get(entry.getKey()),entry.getValue()))unread++;
|
||||
}
|
||||
for(Item item:items)item.read=Objects.equals(reads.get(item.id),item.revision);
|
||||
}
|
||||
private void rebuild(){
|
||||
updateReadingState();int wanted=Math.min(visibleLimit,total), position=0;Integer nextOffset=null;
|
||||
List<Item> visible=new ArrayList<>();Set<Integer> retainedPages=new HashSet<>();summary=false;
|
||||
if(wanted>0)for(int offset=0;offset<catalog.size();offset+=20){
|
||||
List<Long> ids=pageIds.get(offset);if(ids==null){nextOffset=offset;break;}
|
||||
Set<Long> selection=new HashSet<>();
|
||||
for(long id:ids){
|
||||
Long revision=catalog.get(id);if(revision==null||Objects.equals(dismissed.get(id),revision))continue;
|
||||
if(position<wanted)selection.add(id);position++;
|
||||
}
|
||||
if(selection.isEmpty()){pageBodies.remove(offset);continue;}
|
||||
retainedPages.add(offset);List<Item> bodies=pageBodies.get(offset);
|
||||
if(bodies==null){nextOffset=offset;break;}
|
||||
for(Item item:bodies)if(selection.contains(item.id))visible.add(item);
|
||||
if(visible.size()==wanted)break;
|
||||
}
|
||||
if(visible.size()==wanted)pageBodies.keySet().retainAll(retainedPages);
|
||||
else{
|
||||
Set<Long> retained=new HashSet<>();for(Item item:visible)retained.add(item.id);
|
||||
for(Item item:items)if(Objects.equals(catalog.get(item.id),item.revision)&&!isDismissed(item)&&retained.add(item.id)){
|
||||
visible.add(item);if(visible.size()==wanted)break;
|
||||
}
|
||||
}
|
||||
items.clear();items.addAll(visible);loaded=true;updateReadingState();
|
||||
if(nextOffset!=null){pageOffset=nextOffset;request(nextOffset);}
|
||||
}
|
||||
private void loadLocalState(){
|
||||
if(file==null||file.getBaseFile().length()>16*1024*1024)return;
|
||||
try{
|
||||
JSONObject saved=new JSONObject(new String(file.readFully(),StandardCharsets.UTF_8));
|
||||
long version=integer(saved,"version");
|
||||
if(!scope.equals(string(saved,"scope"))||(version!=1&&version!=2))return;
|
||||
loadRevisions(saved.optJSONObject("reads"),reads);loadRevisions(saved.optJSONObject("dismissed"),dismissed);
|
||||
}catch(Exception ignored){}
|
||||
}
|
||||
private static void loadRevisions(JSONObject json,Map<Long,Long> target){
|
||||
if(json==null)return;
|
||||
for(Iterator<String> keys=json.keys();keys.hasNext();){
|
||||
String key=keys.next();
|
||||
try{long id=Long.parseLong(key), revision=integer(json,key);if(id>0&&revision>0)target.put(id,revision);}catch(Exception ignored){}
|
||||
}
|
||||
}
|
||||
private boolean saveLocalState(Map<Long,Long> readValues,Map<Long,Long> dismissedValues){
|
||||
if(file==null)return false;FileOutputStream output=null;
|
||||
try{
|
||||
if(!directory.isDirectory()&&!directory.mkdirs())return false;
|
||||
JSONObject saved=new JSONObject().put("version",2).put("scope",scope)
|
||||
.put("reads",revisions(readValues)).put("dismissed",revisions(dismissedValues));
|
||||
byte[] bytes=saved.toString().getBytes(StandardCharsets.UTF_8);
|
||||
output=file.startWrite();output.write(bytes);output.getFD().sync();file.finishWrite(output);output=null;
|
||||
// AtomicFile logs some rename failures instead of throwing; verify the committed file.
|
||||
return Arrays.equals(bytes,file.readFully());
|
||||
}catch(Exception error){if(output!=null)file.failWrite(output);return false;}
|
||||
}
|
||||
private static JSONObject revisions(Map<Long,Long> values)throws Exception{
|
||||
JSONObject json=new JSONObject();for(Map.Entry<Long,Long> entry:values.entrySet())json.put(entry.getKey().toString(),entry.getValue());return json;
|
||||
}
|
||||
private static long integer(JSONObject json,String key)throws Exception{
|
||||
Object value=json.get(key);if(!(value instanceof Integer)&&!(value instanceof Long))throw new IllegalArgumentException(key);
|
||||
return ((Number)value).longValue();
|
||||
}
|
||||
private static String string(JSONObject json,String key)throws Exception{
|
||||
Object value=json.get(key);if(!(value instanceof String))throw new IllegalArgumentException(key);return (String)value;
|
||||
}
|
||||
@Override public void close(){cancelRequest();connected=false;sender=null;changed=()->{};}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.URLSpan;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Plain text plus HTTP(S) links, matching iOS; never interpret HTML or arbitrary Markdown. */
|
||||
final class AnnouncementText {
|
||||
static CharSequence format(String body){
|
||||
String text=body.replace("\r\n","\n");SpannableStringBuilder result=new SpannableStringBuilder();
|
||||
for(int position=0;position<text.length();){
|
||||
if(text.charAt(position)=='['){
|
||||
int labelEnd=text.indexOf(']',position+1), start=labelEnd+2;
|
||||
if(labelEnd>position+1&&start<text.length()&&text.charAt(labelEnd+1)=='('&&
|
||||
!text.substring(position+1,labelEnd).matches("(?s).*[\\[\\]\\r\\n].*")&&hasScheme(text,start)){
|
||||
int end=urlEnd(text,start);
|
||||
if(end<text.length()&&text.charAt(end)==')'&&
|
||||
appendLink(result,text.substring(position+1,labelEnd),text.substring(start,end))){
|
||||
position=end+1;continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(hasScheme(text,position)){
|
||||
int end=urlEnd(text,position);
|
||||
while(end>position&&".,;:!?".indexOf(text.charAt(end-1))>=0)end--;
|
||||
String address=text.substring(position,end);
|
||||
if(appendLink(result,address,address)){position=end;continue;}
|
||||
}
|
||||
result.append(text.charAt(position++));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static boolean isWebURL(String address){
|
||||
if(address==null)return false;
|
||||
for(int i=0;i<address.length();i++){char c=address.charAt(i);if(c<=0x20||c==0x7f||"\\<>\"".indexOf(c)>=0)return false;}
|
||||
Uri uri=Uri.parse(address);String scheme=uri.getScheme(),host=uri.getHost();
|
||||
return scheme!=null&&(scheme.equalsIgnoreCase("http")||scheme.equalsIgnoreCase("https"))&&host!=null&&!host.isEmpty();
|
||||
}
|
||||
private static boolean hasScheme(String text,int start){
|
||||
String prefix=text.substring(start,Math.min(text.length(),start+8)).toLowerCase(Locale.ROOT);
|
||||
return prefix.startsWith("https://")||prefix.startsWith("http://");
|
||||
}
|
||||
private static int urlEnd(String text,int start){
|
||||
int parentheses=0,brackets=0,end=start;
|
||||
for(;end<text.length();end++){
|
||||
char c=text.charAt(end);
|
||||
if(c<=0x20||c==0x7f||c=='\\'||c==96||"<>\"'{},。;:!?、()【】《》「」『』“”‘’".indexOf(c)>=0)break;
|
||||
if(c=='(')parentheses++;
|
||||
if(c==')'){if(parentheses==0)break;parentheses--;}
|
||||
if(c=='[')brackets++;
|
||||
if(c==']'){if(brackets==0)break;brackets--;}
|
||||
}
|
||||
return end;
|
||||
}
|
||||
private static boolean appendLink(SpannableStringBuilder result,String label,String destination){
|
||||
if(!isWebURL(destination))return false;
|
||||
int start=result.length();result.append(label);
|
||||
result.setSpan(new URLSpan(destination){
|
||||
@Override public void onClick(View widget){
|
||||
if(!isWebURL(getURL()))return;
|
||||
try{widget.getContext().startActivity(new Intent(Intent.ACTION_VIEW,Uri.parse(getURL())).addCategory(Intent.CATEGORY_BROWSABLE));}
|
||||
catch(android.content.ActivityNotFoundException error){Toast.makeText(widget.getContext(),"没有可打开网页的浏览器",Toast.LENGTH_SHORT).show();}
|
||||
}
|
||||
},start,result.length(),Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Color;
|
||||
import android.text.TextUtils;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Grouped announcement list and plain-text detail, sharing the iOS presentation. */
|
||||
@android.annotation.SuppressLint("ViewConstructor")
|
||||
final class AnnouncementView extends ScrollView {
|
||||
private final MobileUi ui;
|
||||
private final AnnouncementInbox inbox;
|
||||
private final LinearLayout content;
|
||||
private final Consumer<AnnouncementInbox.Item> open;
|
||||
private final Runnable authorize;
|
||||
private final boolean detail;
|
||||
private final long id,revision;
|
||||
private final SimpleDateFormat dates=new SimpleDateFormat("yyyy-MM-dd HH:mm",Locale.getDefault());
|
||||
private TextView countLabel;
|
||||
private AlertDialog deleteDialog;
|
||||
private float downX,downY;
|
||||
private boolean pulling,canPull;
|
||||
private final int slop;
|
||||
|
||||
AnnouncementView(MobileUi ui,AnnouncementInbox inbox,AnnouncementInbox.Item selected,
|
||||
Consumer<AnnouncementInbox.Item> open,Runnable authorize){
|
||||
super(ui.context);this.ui=ui;this.inbox=inbox;this.open=open;this.authorize=authorize;
|
||||
detail=selected!=null;id=detail?selected.id:0;revision=detail?selected.revision:0;
|
||||
slop=ViewConfiguration.get(getContext()).getScaledTouchSlop();
|
||||
setFillViewport(true);setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY);
|
||||
content=ui.column();content.setPadding(ui.dp(20),ui.dp(detail?20:16),ui.dp(20),ui.dp(28));
|
||||
addView(content,new ScrollView.LayoutParams(-1,-2));
|
||||
setOnScrollChangeListener((v,x,y,oldX,oldY)->{if(y>oldY)loadVisiblePage();});
|
||||
render();
|
||||
}
|
||||
void render(){
|
||||
int y=getScrollY();content.removeAllViews();countLabel=null;
|
||||
if(detail)renderDetail();else renderList();
|
||||
post(()->{if(isAttachedToWindow()){scrollTo(0,y);loadVisiblePage();}});
|
||||
}
|
||||
private String date(AnnouncementInbox.Item item){return dates.format(new Date(item.updatedAt*1000));}
|
||||
private TextView message(String value,int color){
|
||||
TextView text=ui.text(value,14,false);text.setTextColor(color);text.setLineSpacing(ui.dp(3),1);return text;
|
||||
}
|
||||
private void renderList(){
|
||||
if(inbox.deleteFailed){content.addView(message("无法删除这条公告,请重试。",RED));ui.gap(content,12);}
|
||||
if(!inbox.isConnected()){
|
||||
content.addView(message("连接服务器后可获取最新公告。",SECONDARY));
|
||||
content.addView(ui.action("连接服务器",BLUE,authorize));ui.gap(content,8);
|
||||
}else if(inbox.failed){
|
||||
LinearLayout error=ui.row();error.addView(message("公告加载失败,请重试。",SECONDARY),new LinearLayout.LayoutParams(0,-2,1));
|
||||
error.addView(ui.action("重试",BLUE,inbox::refresh),ui.size(64,48));content.addView(error);ui.gap(content,8);
|
||||
}
|
||||
countLabel=ui.text("共 "+inbox.total+" 条 · "+inbox.unread+" 条未读",13,false);
|
||||
countLabel.setTextColor(SECONDARY);countLabel.setPadding(ui.dp(16),ui.dp(8),0,ui.dp(8));content.addView(countLabel);
|
||||
LinearLayout card=ui.column();ui.card(card,12);content.addView(card);
|
||||
for(AnnouncementInbox.Item item:inbox.items){
|
||||
if(card.getChildCount()>0)ui.divider(card);
|
||||
card.addView(itemRow(item),ui.size(-1,-2));
|
||||
}
|
||||
if(inbox.loading){
|
||||
LinearLayout loading=ui.row();loading.setGravity(Gravity.CENTER);loading.setPadding(0,ui.dp(18),0,ui.dp(18));
|
||||
ProgressBar spinner=new ProgressBar(getContext());spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));loading.addView(spinner,ui.size(20,20));
|
||||
TextView label=message("加载中…",SECONDARY);label.setPadding(ui.dp(10),0,0,0);loading.addView(label);card.addView(loading);
|
||||
}else if(inbox.items.size()<inbox.total&&!inbox.failed){
|
||||
TextView more=ui.action("加载更多",BLUE,inbox::loadMore);more.setEnabled(inbox.isConnected());card.addView(more,ui.size(-1,48));
|
||||
}else if(inbox.loaded&&inbox.total==0&&!inbox.failed&&inbox.isConnected()){
|
||||
TextView empty=message("暂无公告",SECONDARY);empty.setGravity(Gravity.CENTER);card.addView(empty,ui.size(-1,120));
|
||||
}
|
||||
}
|
||||
private View itemRow(AnnouncementInbox.Item item){
|
||||
LinearLayout row=ui.row();row.setBackgroundColor(Color.WHITE);row.setPadding(ui.dp(16),ui.dp(16),ui.dp(12),ui.dp(16));
|
||||
LinearLayout words=ui.column();
|
||||
LinearLayout top=ui.row();top.setGravity(Gravity.TOP);
|
||||
View dot=new View(getContext());dot.setBackground(ui.background(item.read?Color.TRANSPARENT:BLUE,4));
|
||||
LinearLayout.LayoutParams dotLayout=ui.size(7,7);dotLayout.topMargin=ui.dp(7);dotLayout.rightMargin=ui.dp(10);top.addView(dot,dotLayout);
|
||||
TextView title=ui.text(item.title,16,!item.read);title.setMaxLines(2);title.setEllipsize(TextUtils.TruncateAt.END);
|
||||
words.addView(title,ui.size(-1,-2));ui.gap(words,7);TextView date=ui.text(date(item),12,false);date.setTextColor(SECONDARY);words.addView(date);
|
||||
top.addView(words,new LinearLayout.LayoutParams(0,-2,1));row.addView(top,new LinearLayout.LayoutParams(0,-2,1));
|
||||
TextView chevron=ui.text("›",23,false);chevron.setTextColor(0xFFB7B7BD);chevron.setPadding(ui.dp(10),0,0,0);row.addView(chevron);
|
||||
row.setContentDescription(item.title+","+date(item)+","+(item.read?"已读":"未读"));row.setFocusable(true);
|
||||
row.setOnClickListener(v->open.accept(item));row.setOnLongClickListener(v->{confirmDelete(item);return true;});
|
||||
return new SwipeRow(row,()->confirmDelete(item));
|
||||
}
|
||||
private void confirmDelete(AnnouncementInbox.Item item){
|
||||
if(deleteDialog!=null)deleteDialog.dismiss();
|
||||
deleteDialog=new AlertDialog.Builder(getContext()).setTitle("删除公告").setMessage("删除这条公告?")
|
||||
.setNegativeButton("取消",null).setPositiveButton("删除",(dialog,which)->inbox.dismiss(item)).show();
|
||||
}
|
||||
private void renderDetail(){
|
||||
AnnouncementInbox.Item item=inbox.find(id,revision);
|
||||
if(item==null){content.addView(message("这条公告已更新或删除,请返回公告列表查看。",SECONDARY));return;}
|
||||
content.addView(ui.text(item.title,22,true));ui.gap(content,16);
|
||||
TextView date=ui.text(date(item)+" · "+(item.read?"已读":"未读"),12,false);date.setTextColor(SECONDARY);content.addView(date);ui.gap(content,16);
|
||||
if(inbox.readSaveFailed){
|
||||
content.addView(message("无法保存本地阅读状态,请重试。",RED));
|
||||
content.addView(ui.action("重试保存阅读状态",BLUE,()->inbox.markRead(item)));ui.gap(content,16);
|
||||
}
|
||||
TextView body=ui.text("",17,false);body.setText(AnnouncementText.format(item.body));body.setTextIsSelectable(true);
|
||||
body.setMovementMethod(LinkMovementMethod.getInstance());body.setLinksClickable(true);body.setLinkTextColor(BLUE);body.setLineSpacing(ui.dp(4),1);
|
||||
content.addView(body,ui.size(-1,-2));
|
||||
}
|
||||
private void loadVisiblePage(){
|
||||
if(!detail&&isAttachedToWindow()&&inbox.isConnected()&&!inbox.loading&&!inbox.failed&&inbox.items.size()<inbox.total&&
|
||||
content.getHeight()>0&&getHeight()>0&&getScrollY()+getHeight()>=content.getHeight()-ui.dp(48))inbox.loadMore();
|
||||
}
|
||||
private void trackPull(MotionEvent event){
|
||||
if(!detail&&inbox.isConnected()&&!inbox.loading){
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_DOWN){downX=event.getX();downY=event.getY();pulling=false;canPull=getScrollY()==0;}
|
||||
else if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&canPull&&getScrollY()==0&&
|
||||
event.getY()-downY>slop&&event.getY()-downY>Math.abs(event.getX()-downX))pulling=true;
|
||||
}
|
||||
}
|
||||
@Override public boolean onInterceptTouchEvent(MotionEvent event){
|
||||
trackPull(event);return pulling||super.onInterceptTouchEvent(event);
|
||||
}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // Pull gesture only; toolbar refresh is the accessible action.
|
||||
@Override public boolean onTouchEvent(MotionEvent event){
|
||||
trackPull(event);
|
||||
if(!pulling)return super.onTouchEvent(event);
|
||||
boolean reached=event.getY()-downY>=ui.dp(72);
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&countLabel!=null)countLabel.setText(reached?"松开刷新":"下拉刷新");
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL){
|
||||
pulling=false;if(reached&&event.getActionMasked()==MotionEvent.ACTION_UP)inbox.refresh();else render();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@Override protected void onDetachedFromWindow(){if(deleteDialog!=null)deleteDialog.dismiss();super.onDetachedFromWindow();}
|
||||
|
||||
/** Reveal a delete action without interfering with vertical scrolling or row taps. */
|
||||
private final class SwipeRow extends FrameLayout {
|
||||
private final View front,remove;
|
||||
private float x,y,origin;
|
||||
private boolean dragging;
|
||||
SwipeRow(View front,Runnable delete){
|
||||
super(ui.context);this.front=front;setClipChildren(true);
|
||||
remove=ui.action("删除",Color.WHITE,delete);remove.setBackgroundColor(RED);
|
||||
remove.setVisibility(INVISIBLE);
|
||||
remove.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);remove.setFocusable(false);
|
||||
addView(remove,new FrameLayout.LayoutParams(ui.dp(76),-1,Gravity.END));addView(front,new FrameLayout.LayoutParams(-1,-2));
|
||||
}
|
||||
@Override public boolean onInterceptTouchEvent(MotionEvent event){
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_DOWN){front.animate().cancel();x=event.getX();y=event.getY();origin=front.getTranslationX();dragging=false;}
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&Math.abs(event.getX()-x)>slop&&Math.abs(event.getX()-x)>Math.abs(event.getY()-y)){
|
||||
dragging=true;remove.setVisibility(VISIBLE);getParent().requestDisallowInterceptTouchEvent(true);return true;
|
||||
}
|
||||
return super.onInterceptTouchEvent(event);
|
||||
}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // Taps/long presses belong to the accessible row and delete button.
|
||||
@Override public boolean onTouchEvent(MotionEvent event){
|
||||
if(!dragging)return super.onTouchEvent(event);
|
||||
int direction=getLayoutDirection()==LAYOUT_DIRECTION_RTL?1:-1;
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_MOVE)front.setTranslationX(direction*Math.max(0,Math.min(ui.dp(76),direction*(origin+event.getX()-x))));
|
||||
if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL){
|
||||
boolean expanded=Math.abs(front.getTranslationX())>ui.dp(38);dragging=false;
|
||||
front.animate().translationX(expanded?direction*ui.dp(76):0).setDuration(150).withEndAction(()->{if(!expanded)remove.setVisibility(INVISIBLE);}).start();
|
||||
remove.setFocusable(expanded);remove.setImportantForAccessibility(expanded?IMPORTANT_FOR_ACCESSIBILITY_YES:IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioTrack;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
|
||||
/** Bounded audio queue: network callbacks never block on the speaker. */
|
||||
final class AudioPlayer implements AutoCloseable {
|
||||
private final ArrayBlockingQueue<byte[]> queue = new ArrayBlockingQueue<>(20);
|
||||
private volatile boolean closed;
|
||||
private volatile boolean enabled = true;
|
||||
private final Thread thread = new Thread(this::play, "CrossDesk audio");
|
||||
AudioPlayer() { thread.start(); }
|
||||
void offer(byte[] pcm) {
|
||||
if (!closed && enabled && !queue.offer(pcm)) { queue.poll(); queue.offer(pcm); }
|
||||
}
|
||||
void setEnabled(boolean value) { enabled = value; queue.clear(); }
|
||||
private void play() {
|
||||
AudioTrack track = null;
|
||||
try {
|
||||
int minimum = AudioTrack.getMinBufferSize(48000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT);
|
||||
track = new AudioTrack.Builder()
|
||||
.setAudioAttributes(new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build())
|
||||
.setAudioFormat(new AudioFormat.Builder().setSampleRate(48000)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).setEncoding(AudioFormat.ENCODING_PCM_16BIT).build())
|
||||
.setBufferSizeInBytes(Math.max(minimum, 9600)).setTransferMode(AudioTrack.MODE_STREAM).build();
|
||||
track.play();
|
||||
while (!closed) {
|
||||
byte[] data = queue.take();
|
||||
if (!enabled) continue;
|
||||
int offset = 0;
|
||||
while (!closed && enabled && offset < data.length) {
|
||||
int written = track.write(data, offset, data.length - offset, AudioTrack.WRITE_BLOCKING);
|
||||
if (written <= 0) break;
|
||||
offset += written;
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (IllegalArgumentException | IllegalStateException ignored) {
|
||||
// Devices with unavailable output can still operate as a silent controller.
|
||||
} finally {
|
||||
if (track != null) { track.release(); }
|
||||
}
|
||||
}
|
||||
@Override public void close() { closed = true; queue.clear(); thread.interrupt(); }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
final class KeyMap {
|
||||
static int windowsCode(int code) {
|
||||
if (code >= KeyEvent.KEYCODE_A && code <= KeyEvent.KEYCODE_Z) return 0x41 + code - KeyEvent.KEYCODE_A;
|
||||
if (code >= KeyEvent.KEYCODE_0 && code <= KeyEvent.KEYCODE_9) return 0x30 + code - KeyEvent.KEYCODE_0;
|
||||
if (code >= KeyEvent.KEYCODE_F1 && code <= KeyEvent.KEYCODE_F12) return 0x70 + code - KeyEvent.KEYCODE_F1;
|
||||
return switch (code) {
|
||||
case KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> 0x0D;
|
||||
case KeyEvent.KEYCODE_DEL -> 0x08;
|
||||
case KeyEvent.KEYCODE_FORWARD_DEL -> 0x2E;
|
||||
case KeyEvent.KEYCODE_TAB -> 0x09;
|
||||
case KeyEvent.KEYCODE_ESCAPE -> 0x1B;
|
||||
case KeyEvent.KEYCODE_SPACE -> 0x20;
|
||||
case KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT -> 0x10;
|
||||
case KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT -> 0x11;
|
||||
case KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT -> 0x12;
|
||||
case KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT -> 0x5B;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT -> 0x25;
|
||||
case KeyEvent.KEYCODE_DPAD_UP -> 0x26;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN -> 0x28;
|
||||
case KeyEvent.KEYCODE_MOVE_HOME -> 0x24;
|
||||
case KeyEvent.KEYCODE_MOVE_END -> 0x23;
|
||||
case KeyEvent.KEYCODE_PAGE_UP -> 0x21;
|
||||
case KeyEvent.KEYCODE_PAGE_DOWN -> 0x22;
|
||||
case KeyEvent.KEYCODE_MINUS -> 0xBD;
|
||||
case KeyEvent.KEYCODE_EQUALS -> 0xBB;
|
||||
case KeyEvent.KEYCODE_LEFT_BRACKET -> 0xDB;
|
||||
case KeyEvent.KEYCODE_RIGHT_BRACKET -> 0xDD;
|
||||
case KeyEvent.KEYCODE_BACKSLASH -> 0xDC;
|
||||
case KeyEvent.KEYCODE_SEMICOLON -> 0xBA;
|
||||
case KeyEvent.KEYCODE_APOSTROPHE -> 0xDE;
|
||||
case KeyEvent.KEYCODE_COMMA -> 0xBC;
|
||||
case KeyEvent.KEYCODE_PERIOD -> 0xBE;
|
||||
case KeyEvent.KEYCODE_SLASH -> 0xBF;
|
||||
case KeyEvent.KEYCODE_GRAVE -> 0xC0;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
// High bit is the Shift modifier; zero means use the clipboard for this text.
|
||||
static int ascii(char ch) {
|
||||
if (ch >= 'a' && ch <= 'z') return ch - 'a' + 0x41;
|
||||
if (ch >= 'A' && ch <= 'Z') return ch | 0x100;
|
||||
if (ch >= '0' && ch <= '9') return ch;
|
||||
if (ch == '\n') return 0x0D;
|
||||
if (ch == '\t') return 0x09;
|
||||
if (ch == ' ') return 0x20;
|
||||
String plain = "-=[]\\;',./`";
|
||||
String shifted = "_+{}|:\"<>?~";
|
||||
int[] codes = {0xBD,0xBB,0xDB,0xDD,0xDC,0xBA,0xDE,0xBC,0xBE,0xBF,0xC0};
|
||||
int i = plain.indexOf(ch);
|
||||
if (i >= 0) return codes[i];
|
||||
i = shifted.indexOf(ch);
|
||||
if (i >= 0) return codes[i] | 0x100;
|
||||
i = ")!@#$%^&*(".indexOf(ch);
|
||||
return i < 0 ? 0 : (0x30 + i) | 0x100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Offline catalog; document bodies are loaded only when opened. */
|
||||
final class LicenseCatalog {
|
||||
static final class Document {
|
||||
final String title,asset;
|
||||
Document(JSONObject value)throws JSONException{
|
||||
title=value.getString("title");asset=value.getString("asset");
|
||||
if(!asset.startsWith("about/")||asset.contains(".."))throw new JSONException("Invalid asset");
|
||||
}
|
||||
}
|
||||
static final class Component {
|
||||
final String id,name,version,license,sourceURL,buildSourceURL;
|
||||
final List<Document> documents,buildDocuments;
|
||||
Component(JSONObject value)throws JSONException{
|
||||
id=value.getString("id");name=value.getString("name");version=value.getString("version");license=value.getString("license");
|
||||
sourceURL=value.getString("sourceURL");buildSourceURL=value.optString("buildSourceURL","");
|
||||
documents=documents(value.getJSONArray("documents"));buildDocuments=documents(value.optJSONArray("buildDocuments"));
|
||||
if(documents.isEmpty())throw new JSONException("Missing documents");
|
||||
}
|
||||
String documentTitle(Document document){
|
||||
if(document.title.equals("Source copyright and license notices"))return "版权与许可声明";
|
||||
if(document.title.equals("apps/android/licenses/OPEN_SOURCE_NOTICE.txt"))return "开源软件权利说明";
|
||||
if(id.equals("crossdesk")&&document.title.equals("LICENSE"))return "GNU GPL 第 3 版";
|
||||
return document.title;
|
||||
}
|
||||
}
|
||||
final List<Component> components=new ArrayList<>();
|
||||
final JSONObject source;
|
||||
LicenseCatalog(Context context)throws IOException,JSONException{
|
||||
JSONObject data=new JSONObject(read(context,"ThirdPartyLicenses.json"));
|
||||
if(data.getInt("schemaVersion")!=1)throw new JSONException("Unknown catalog");
|
||||
JSONArray entries=data.getJSONArray("components");
|
||||
for(int i=0;i<entries.length();i++)components.add(new Component(entries.getJSONObject(i)));
|
||||
if(find("crossdesk")==null||find("openfec")==null)throw new JSONException("Incomplete catalog");
|
||||
JSONObject metadata;
|
||||
try{metadata=new JSONObject(read(context,"SourceMetadata.json"));if(metadata.getInt("schemaVersion")!=1)metadata=null;}catch(IOException|JSONException error){metadata=null;}
|
||||
source=metadata;
|
||||
}
|
||||
Component find(String id){for(Component component:components)if(component.id.equals(id))return component;return null;}
|
||||
static List<Document> documents(JSONArray values)throws JSONException{
|
||||
List<Document> result=new ArrayList<>();if(values!=null)for(int i=0;i<values.length();i++)result.add(new Document(values.getJSONObject(i)));return result;
|
||||
}
|
||||
static String read(Context context,String asset)throws IOException{
|
||||
try(var input=context.getAssets().open(asset);var output=new ByteArrayOutputStream()){
|
||||
byte[] buffer=new byte[8192];int count;while((count=input.read(buffer))!=-1)output.write(buffer,0,count);return output.toString("UTF-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.*;
|
||||
import android.text.method.PasswordTransformationMethod;
|
||||
import android.view.*;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.*;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
public final class MainActivity extends Activity implements NativeSession.Listener {
|
||||
private MobileUi ui;
|
||||
private PageHost pageHost;
|
||||
private AboutPages aboutPages;
|
||||
private boolean navigatingBack;
|
||||
private FrameLayout canvas;
|
||||
private LinearLayout root;
|
||||
private EditText remote;
|
||||
private TextView status, signalBadge, identitySummary;
|
||||
private AnnouncementInbox announcementInbox;
|
||||
private AnnouncementView announcementView;
|
||||
private TextView announcementBadge;
|
||||
private View announcementRefresh;
|
||||
private String announcementServer="";
|
||||
private SharedPreferences preferences;
|
||||
private RecentConnections history;
|
||||
private RemotePreviewStore previews;
|
||||
private RemotePreviewStore.Capture previewCapture;
|
||||
private boolean previewCopying,updatingPreviewSwitch;
|
||||
private int previewAttempts;
|
||||
private Switch previewSwitch;
|
||||
private TextView clearPreviewsButton,previewCleanupError;
|
||||
private AlertDialog previewDialog;
|
||||
private final java.util.List<Runnable> previewCards=new java.util.ArrayList<>();
|
||||
private final Runnable previewChanged=this::renderPreviews;
|
||||
private NativeSession session, signaling;
|
||||
private String signalingServer="", localIdentity="", signalError="";
|
||||
private int signalState=-1;
|
||||
private boolean foreground;
|
||||
private RemoteVideoView video;
|
||||
private SessionControls controls;
|
||||
private Dialog passwordDialog, progressDialog;
|
||||
private AlertDialog disconnectDialog;
|
||||
private AlertDialog remoteUpdateDialog;
|
||||
private RemoteVersionCheck remoteVersionCheck;
|
||||
private String host, connectedRemote="", hostPlatform="", remoteClipboard="", pendingPassword="", page="home";
|
||||
private int port;
|
||||
private JSONArray displays=new JSONArray();
|
||||
private boolean muted,ready,rememberPassword;
|
||||
private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1);
|
||||
private final android.os.Handler mainHandler=new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private Runnable videoSettingsTimeout;
|
||||
private final Set<Integer> pressed=new HashSet<>();
|
||||
private Runnable backAction;
|
||||
|
||||
@Override public void onCreate(Bundle state){
|
||||
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);
|
||||
pageHost=new PageHost(this);setContentView(pageHost);
|
||||
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099);
|
||||
remoteVersionCheck=new RemoteVersionCheck(this::showRemoteUpdate);
|
||||
previews=RemotePreviewStore.get(this);previews.addListener(previewChanged);previews.enforceConsent();
|
||||
announcementInbox=new AnnouncementInbox(new java.io.File(getNoBackupFilesDir(),"AnnouncementReads"));
|
||||
announcementInbox.sender=(request,id)->{if(signaling!=null&&!signaling.isClosed())signaling.announcementRequest(request,id);else announcementInbox.failed(id);};
|
||||
announcementInbox.changed=this::renderAnnouncements;
|
||||
if(android.os.Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::back);
|
||||
home("");
|
||||
}
|
||||
private int dp(float value){return ui.dp(value);}
|
||||
private LinearLayout column(){return ui.column();}
|
||||
private LinearLayout row(){return ui.row();}
|
||||
private TextView text(String title,int size,boolean bold){return ui.text(title,size,bold);}
|
||||
private String serverKey(){return host+":"+port;}
|
||||
private void toast(String value){Toast.makeText(this,value,Toast.LENGTH_SHORT).show();}
|
||||
private void hideKeyboard(){((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(root.getWindowToken(),0);root.clearFocus();}
|
||||
private void installRoot(String nextPage,boolean dark){
|
||||
PageHost.Motion motion=PageHost.Motion.NONE;
|
||||
if(canvas!=null&&foreground&&!page.equals(nextPage)){
|
||||
if(nextPage.equals("session"))motion=PageHost.Motion.SESSION_IN;
|
||||
else if(page.equals("session"))motion=PageHost.Motion.SESSION_OUT;
|
||||
else motion=navigatingBack||nextPage.equals("home")?PageHost.Motion.POP:PageHost.Motion.PUSH;
|
||||
}
|
||||
if(root!=null)hideKeyboard();page=nextPage;
|
||||
signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null;
|
||||
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();
|
||||
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
|
||||
root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1));
|
||||
root.setOnApplyWindowInsetsListener((view,insets)->{
|
||||
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
|
||||
});
|
||||
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
||||
applySystemBars(dark);
|
||||
}
|
||||
private void applySystemBars(boolean dark){
|
||||
if(android.os.Build.VERSION.SDK_INT>=30){
|
||||
android.view.WindowInsetsController controller=getWindow().getInsetsController();
|
||||
if(controller!=null){int light=android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS|android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;controller.setSystemBarsAppearance(dark?0:light,light);}
|
||||
}else getWindow().getDecorView().setSystemUiVisibility(dark?0:View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR|View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
}
|
||||
private EditText field(String hint,int type){
|
||||
EditText v=new EditText(this);v.setHint(hint);v.setContentDescription(hint);v.setInputType(type);v.setSingleLine(true);v.setTextSize(17);v.setTextColor(INK);v.setHintTextColor(SECONDARY);
|
||||
v.setPadding(dp(14),0,dp(14),0);v.setBackground(ui.background(BACKGROUND,12));v.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);return v;
|
||||
}
|
||||
private LinearLayout navigation(String title,Runnable back){
|
||||
LinearLayout bar=row();bar.setPadding(dp(12),0,dp(12),0);root.addView(bar,ui.size(-1,56));
|
||||
bar.addView(ui.iconButton("back","返回",this::navigateBack),ui.size(44,44));
|
||||
TextView label=text(title,17,true);label.setGravity(Gravity.CENTER);label.setSingleLine();label.setEllipsize(TextUtils.TruncateAt.END);bar.addView(label,new LinearLayout.LayoutParams(0,-1,1));
|
||||
bar.addView(new View(this),ui.size(44,44));backAction=back;return bar;
|
||||
}
|
||||
LinearLayout formPage(String name,String title,Runnable back){
|
||||
installRoot(name,false);navigation(title,back);ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));
|
||||
LinearLayout content=column();content.setPadding(dp(20),dp(16),dp(20),dp(28));scroll.addView(content);return content;
|
||||
}
|
||||
LinearLayout section(LinearLayout content,String title){
|
||||
if(!title.isEmpty()){TextView label=text(title,13,false);label.setTextColor(SECONDARY);label.setPadding(dp(16),dp(12),0,dp(8));content.addView(label);}
|
||||
LinearLayout card=column();ui.card(card,12);content.addView(card);return card;
|
||||
}
|
||||
void footer(LinearLayout content,String title){TextView v=text(title,12,false);v.setTextColor(SECONDARY);v.setLineSpacing(dp(3),1);v.setPadding(dp(16),dp(8),dp(16),dp(16));content.addView(v);}
|
||||
void link(LinearLayout parent,String title,String detail,Runnable action){
|
||||
LinearLayout item=row();item.setPadding(dp(16),dp(14),dp(12),dp(14));item.setMinimumHeight(dp(48));
|
||||
item.addView(text(title,16,false),new LinearLayout.LayoutParams(0,-2,1));TextView value=text(detail,15,false);value.setTextColor(SECONDARY);item.addView(value);
|
||||
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
|
||||
}
|
||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Match the iOS portrait home; sessions support both orientations.
|
||||
private void home(String message){
|
||||
backAction=null;setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);installRoot("home",false);
|
||||
LinearLayout toolbar=row();toolbar.setPadding(dp(20),0,dp(20),0);root.addView(toolbar,ui.size(-1,56));
|
||||
signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true);
|
||||
signalBadge.setOnClickListener(v->{
|
||||
if(!preferences.getBoolean("networkConsent",false))privacyPage();
|
||||
else if(signalState==1)toast("已连接 "+serverKey());
|
||||
else {stopSignaling();ensureSignaling();}
|
||||
});renderSignaling();toolbar.addView(signalBadge,ui.size(-2,34));toolbar.addView(new View(this),new LinearLayout.LayoutParams(0,1,1));
|
||||
FrameLayout bell=(FrameLayout)ui.iconButton("bell","通知公告",this::announcements);bell.setClipChildren(false);toolbar.setClipChildren(false);
|
||||
announcementBadge=text("",10,true);announcementBadge.setTextColor(Color.WHITE);announcementBadge.setGravity(Gravity.CENTER);announcementBadge.setMinWidth(dp(16));announcementBadge.setPadding(dp(4),0,dp(4),0);announcementBadge.setBackground(ui.background(RED,12));announcementBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
FrameLayout.LayoutParams badgePosition=new FrameLayout.LayoutParams(-2,dp(16),Gravity.TOP|Gravity.END);badgePosition.topMargin=-dp(3);badgePosition.setMarginEnd(-dp(5));bell.addView(announcementBadge,badgePosition);renderAnnouncements();
|
||||
toolbar.addView(bell,ui.size(40,40));View spacer=new View(this);toolbar.addView(spacer,ui.size(12,1));toolbar.addView(ui.iconButton("settings","设置",this::settings),ui.size(40,40));
|
||||
LinearLayout content=column();content.setPadding(dp(20),dp(24),dp(20),dp(12));root.addView(content,new LinearLayout.LayoutParams(-1,0,1));
|
||||
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));ui.card(connection,20);connection.setElevation(dp(2));content.addView(connection);
|
||||
connection.addView(text("远程桌面",20,true));ui.gap(connection,16);
|
||||
LinearLayout entry=row();connection.addView(entry,ui.size(-1,54));
|
||||
remote=field("对端 ID",InputType.TYPE_CLASS_NUMBER);remote.setTextSize(20);remote.setTypeface(Typeface.create("sans-serif-medium",Typeface.NORMAL));
|
||||
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
|
||||
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
|
||||
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
|
||||
TextView connect=ui.primary("连接 →",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
|
||||
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
|
||||
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
|
||||
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
|
||||
JSONArray records=history.list(serverKey());LinearLayout heading=row();heading.addView(text("最近连接",20,true),new LinearLayout.LayoutParams(0,-2,1));
|
||||
if(records.length()>0){TextView count=text(records.length()+" 台设备",12,false);count.setTextColor(SECONDARY);heading.addView(count);}recent.addView(heading);
|
||||
if(records.length()==0){
|
||||
LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12);
|
||||
TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint);
|
||||
}else{
|
||||
ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout grid=column();scroll.addView(grid);
|
||||
for(int i=0;i<records.length();i+=2){LinearLayout line=row();line.setGravity(Gravity.TOP);grid.addView(line);for(int j=i;j<i+2;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(grid,12);}
|
||||
}
|
||||
ensureSignaling();
|
||||
if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show();
|
||||
}
|
||||
private String id(){return remote.getText().toString().replace(" ","");}
|
||||
static String formatId(String value){String digits=value.replaceAll("[^0-9]","");if(digits.length()>9)digits=digits.substring(0,9);return digits.replaceAll("(.{3})(?=.)","$1 ");}
|
||||
private View recentCard(JSONObject item){
|
||||
if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform");
|
||||
LinearLayout card=column();ui.card(card,14);FrameLayout preview=new FrameLayout(this){@Override protected void onMeasure(int w,int h){super.onMeasure(w,MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(w)*9/16,MeasureSpec.EXACTLY));}};
|
||||
int color=platform.equals("windows")?0xFF316AB7:platform.equals("macos")?0xFF8057B9:platform.equals("linux")?0xFFAE642B:0xFF677587;preview.setBackgroundColor(color);
|
||||
TextView label=text(platform.equals("windows")?"Windows":platform.equals("macos")?"macOS":platform.equals("linux")?"Linux":"远程电脑",19,true);label.setGravity(Gravity.CENTER);label.setTextColor(Color.WHITE);preview.addView(label,new FrameLayout.LayoutParams(-1,-1));card.addView(preview,ui.size(-1,80));
|
||||
ImageView image=new ImageView(this);image.setScaleType(ImageView.ScaleType.CENTER_CROP);image.setVisibility(View.GONE);image.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);preview.addView(image,new FrameLayout.LayoutParams(-1,-1));
|
||||
String server=serverKey();Runnable load=()->{if(!image.isAttachedToWindow())return;Object binding=new Object();image.setTag(binding);image.setImageDrawable(null);image.setVisibility(View.GONE);label.setVisibility(View.VISIBLE);
|
||||
previews.load(server,id,bitmap->{if(image.getTag()!=binding||!image.isAttachedToWindow()){if(bitmap!=null)bitmap.recycle();return;}if(bitmap!=null){image.setImageBitmap(bitmap);image.setVisibility(View.VISIBLE);label.setVisibility(View.GONE);}});};previewCards.add(load);image.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener(){public void onViewAttachedToWindow(View view){load.run();}public void onViewDetachedFromWindow(View view){image.setTag(null);}});if(image.isAttachedToWindow())load.run();
|
||||
LinearLayout caption=column();caption.setPadding(dp(9),dp(9),dp(9),dp(9));TextView name=text(item.optString("name",id),12,true);name.setSingleLine();name.setEllipsize(TextUtils.TruncateAt.END);caption.addView(name);ui.gap(caption,4);
|
||||
TextView detail=text("ID "+id+(item.optBoolean("remember")?" · 已保存密码":""),10,false);detail.setTextColor(SECONDARY);caption.addView(detail);card.addView(caption);
|
||||
card.setContentDescription("连接 "+item.optString("name",id));card.setClickable(true);card.setFocusable(true);card.setOnClickListener(v->{remote.setText(formatId(id));promptConnection(id);});
|
||||
card.setOnLongClickListener(v->{new AlertDialog.Builder(this).setTitle(item.optString("name",id)).setItems(new String[]{"连接","删除记录"},(d,w)->{if(w==0)promptConnection(id);else{history.delete(serverKey(),id);home("");}}).show();return true;});return card;
|
||||
}
|
||||
private void promptConnection(String id){
|
||||
hideKeyboard();if(!id.matches("[0-9]{9}")){remote.setError("请输入 9 位设备 ID");remote.requestFocus();return;}
|
||||
if(!preferences.getBoolean("networkConsent",false)){consent(()->connectOrPrompt(id));return;}connectOrPrompt(id);
|
||||
}
|
||||
private void connectOrPrompt(String id){
|
||||
String stored;
|
||||
try{stored=history.password(serverKey(),id);}catch(Exception error){passwordSheet(id,"已保存密码无法读取,请重新输入",true);return;}
|
||||
if(!stored.isEmpty()){rememberPassword=true;begin(id,stored);return;}
|
||||
boolean remember=history.remembersPassword(serverKey(),id);
|
||||
passwordSheet(id,remember?"已保存的密码未能保留,请重新输入一次":"",remember);
|
||||
}
|
||||
private void consent(Runnable accepted){
|
||||
new AlertDialog.Builder(this).setTitle("隐私与授权").setMessage("连接时会向所选服务器发送设备身份和认证信息,并传输远程画面、声音与操作指令。设备身份加密保存在本机;仅在你开启“保存密码”时加密保存访问密码。剪贴板内容仅在你操作时发送或写入本机。仅连接你有权访问的电脑。")
|
||||
.setNegativeButton("暂不同意",null).setPositiveButton("同意并继续",(d,w)->{preferences.edit().putBoolean("networkConsent",true).apply();ensureSignaling();accepted.run();}).show();
|
||||
}
|
||||
private void passwordSheet(String id,String error,boolean rememberDefault){
|
||||
Dialog dialog=new Dialog(this);passwordDialog=dialog;LinearLayout sheet=column();sheet.setPadding(dp(22),dp(10),dp(22),dp(22));sheet.setBackground(ui.background(BACKGROUND,24));
|
||||
View handle=new View(this);handle.setBackground(ui.background(0xFFC6C6CB,3));LinearLayout.LayoutParams hp=ui.size(36,5);hp.gravity=Gravity.CENTER;sheet.addView(handle,hp);ui.gap(sheet,20);
|
||||
LinearLayout header=row(),titles=column();titles.addView(text("连接远程桌面",22,true));ui.gap(titles,5);TextView subtitle=text("对端 ID "+id,15,false);subtitle.setTextColor(SECONDARY);titles.addView(subtitle);header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭",dialog::dismiss),ui.size(36,36));sheet.addView(header);ui.gap(sheet,18);
|
||||
LinearLayout passwordRow=row();ui.card(passwordRow,10);EditText password=field("访问密码",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);password.setBackgroundColor(Color.TRANSPARENT);passwordRow.addView(password,new LinearLayout.LayoutParams(0,dp(50),1));
|
||||
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
|
||||
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
|
||||
remember.setChecked(rememberDefault);
|
||||
ui.gap(sheet,18);TextView connect=ui.primary("连接 →",()->{
|
||||
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
|
||||
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
||||
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
|
||||
dialog.setContentView(sheet);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();if(window!=null){window.setLayout(-1,-2);window.setGravity(Gravity.BOTTOM);}password.requestFocus();if(!error.isEmpty())password.setError(error);
|
||||
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
|
||||
}
|
||||
private void begin(String id,String secret){
|
||||
resetRemoteVersionCheck();
|
||||
resetVideoSettings();
|
||||
previewCapture=previews.begin(serverKey(),id);previewCopying=false;previewAttempts=0;
|
||||
connectedRemote=id;pendingPassword=rememberPassword?secret:"";ready=false;muted=false;hostPlatform="";remoteClipboard="";displays=new JSONArray();preferences.edit().putString("lastRemote",id).apply();
|
||||
ensureSignaling();
|
||||
if(signaling==null){pendingPassword="";toast("请先完成联网授权");return;}
|
||||
session=signaling;showProgress();session.connect(id,secret);
|
||||
}
|
||||
private void showProgress(){
|
||||
Dialog dialog=new Dialog(this);progressDialog=dialog;LinearLayout card=column();ui.card(card,24);card.setGravity(Gravity.CENTER_HORIZONTAL);LinearLayout body=column();body.setGravity(Gravity.CENTER);body.setPadding(dp(26),dp(24),dp(26),dp(20));
|
||||
ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46));
|
||||
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
|
||||
}
|
||||
private void sessionScreen(){
|
||||
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
|
||||
controls=new SessionControls(this,ui,new SessionControls.Actions(){
|
||||
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
|
||||
public void key(int code){tapKey(code);}
|
||||
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
|
||||
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
|
||||
public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);}
|
||||
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
|
||||
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
|
||||
private void end(String reason){
|
||||
resetRemoteVersionCheck();
|
||||
resetVideoSettings();
|
||||
previewCapture=null;previewCopying=false;
|
||||
if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}if(session!=null){releaseKeys();session.close();session=null;}stopSignaling();pendingPassword="";remoteClipboard="";hostPlatform="";displays=new JSONArray();ready=false;video=null;controls=null;getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);home(reason);
|
||||
}
|
||||
private void settings(){
|
||||
LinearLayout content=formPage("settings","设置",()->home(""));
|
||||
LinearLayout mouse=section(content,"鼠标控制");mouse.setPadding(dp(12),dp(12),dp(12),dp(12));boolean relative=preferences.getBoolean("relativeMouse",true);
|
||||
TextView detail=text(relative?"像触控板一样滑动光标,点击时操作当前光标位置。":"触摸位置直接对应远端屏幕位置,适合快速定位。",13,false);detail.setTextColor(SECONDARY);
|
||||
mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->{preferences.edit().putBoolean("relativeMouse",index==0).apply();detail.setText(index==0?"像触控板一样滑动光标,点击时操作当前光标位置。":"触摸位置直接对应远端屏幕位置,适合快速定位。");}));ui.gap(mouse,10);mouse.addView(detail);
|
||||
LinearLayout picture=section(content,"画面偏好");picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));TextView preferenceDetail=text(RemoteVideoSettings.DETAILS[preference],13,false);preferenceDetail.setTextColor(SECONDARY);
|
||||
picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->{preferences.edit().putInt("videoPreference",index).apply();preferenceDetail.setText(RemoteVideoSettings.DETAILS[index]);}));ui.gap(picture,10);picture.addView(preferenceDetail);ui.gap(content,28);
|
||||
LinearLayout server=section(content,"");LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
|
||||
link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
|
||||
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
|
||||
LinearLayout inputs=column();inputs.setPadding(dp(12),0,dp(12),dp(12));EditText address=field("服务器地址",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_URI),number=field("服务器端口",InputType.TYPE_CLASS_NUMBER);address.setText(custom?host:"");number.setText(custom?String.valueOf(port):"");inputs.addView(address,ui.size(-1,48));ui.gap(inputs,8);inputs.addView(number,ui.size(-1,48));ui.gap(inputs,8);
|
||||
inputs.addView(ui.action("完成",BLUE,()->{if(saveServer(address,number)){hideKeyboard();settings();}}));fields.addView(inputs);inputs.setVisibility(custom?View.VISIBLE:View.GONE);
|
||||
toggle.setOnCheckedChangeListener((button,checked)->{inputs.setVisibility(checked?View.VISIBLE:View.GONE);if(!checked){host="api.crossdesk.cn";port=9099;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();} });
|
||||
backAction=()->{if(toggle.isChecked()&&!saveServer(address,number))return;hideKeyboard();home("");};
|
||||
identitySummary=text("",12,false);identitySummary.setTextColor(SECONDARY);identitySummary.setGravity(Gravity.CENTER);identitySummary.setPadding(dp(16),dp(12),dp(16),dp(16));identitySummary.setTextIsSelectable(true);content.addView(identitySummary);renderSignaling();
|
||||
LinearLayout privacy=section(content,"隐私");link(privacy,"隐私与授权",preferences.getBoolean("networkConsent",false)?"已授权":"未授权",this::privacyPage);ui.divider(privacy);
|
||||
LinearLayout preview=row();preview.setPadding(dp(16),dp(10),dp(16),dp(10));preview.addView(text("保存远程画面预览",16,false),new LinearLayout.LayoutParams(0,-2,1));previewSwitch=new Switch(this);previewSwitch.setContentDescription("保存远程画面预览");preview.addView(previewSwitch);privacy.addView(preview);
|
||||
previewSwitch.setOnCheckedChangeListener((button,checked)->{if(updatingPreviewSwitch)return;if(checked){renderPreviews();confirmPreviewSaving();}else previews.setEnabled(false);});
|
||||
ui.divider(privacy);clearPreviewsButton=ui.action("清除预览图",RED,this::confirmClearPreviews);clearPreviewsButton.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);clearPreviewsButton.setTypeface(Typeface.DEFAULT);clearPreviewsButton.setPadding(dp(16),0,dp(16),0);privacy.addView(clearPreviewsButton,ui.size(-1,48));
|
||||
previewCleanupError=text("",12,false);previewCleanupError.setTextColor(RED);previewCleanupError.setPadding(dp(16),dp(8),dp(16),dp(12));privacy.addView(previewCleanupError);renderPreviews();
|
||||
footer(content,"画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和密码不受影响。");LinearLayout about=section(content,"");link(about,"关于","",this::about);
|
||||
}
|
||||
private void confirmPreviewSaving(){
|
||||
previewDialog=new AlertDialog.Builder(this).setTitle("保存远程画面预览?")
|
||||
.setMessage("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。")
|
||||
.setNegativeButton("取消",null).setPositiveButton("开启保存",(d,w)->previews.setEnabled(true)).show();
|
||||
previewDialog.setOnDismissListener(d->{previewDialog=null;renderPreviews();});
|
||||
}
|
||||
private void confirmClearPreviews(){
|
||||
previewDialog=new AlertDialog.Builder(this).setTitle("清除预览图?")
|
||||
.setMessage("将删除本机保存的所有远程预览图,保留连接记录和密码。"+(previews.enabled()?"后续连接仍会保存新的预览图。":""))
|
||||
.setNegativeButton("取消",null).setPositiveButton("清除",(d,w)->previews.clear()).show();
|
||||
previewDialog.setOnDismissListener(d->previewDialog=null);
|
||||
}
|
||||
private void renderPreviews(){
|
||||
if(previews==null)return;
|
||||
if(!previews.enabled()||previews.cleaning)previewCapture=null;
|
||||
if(previewSwitch!=null){updatingPreviewSwitch=true;previewSwitch.setChecked(previews.enabled());previewSwitch.setEnabled(preferences.getBoolean("networkConsent",false)&&!previews.cleaning);updatingPreviewSwitch=false;}
|
||||
if(clearPreviewsButton!=null){clearPreviewsButton.setText(previews.cleaning?"正在清除…":"清除预览图");clearPreviewsButton.setEnabled(!previews.cleaning);}
|
||||
if(previewCleanupError!=null){previewCleanupError.setText(previews.cleanupError);previewCleanupError.setVisibility(previews.cleanupError.isEmpty()?View.GONE:View.VISIBLE);}
|
||||
for(Runnable card:previewCards)card.run();
|
||||
}
|
||||
private void capturePreviewIfNeeded(){
|
||||
if(!ready||video==null||previewCapture==null||previewCopying||!previews.current(previewCapture))return;
|
||||
if(!history.contains(serverKey(),connectedRemote)){previewCapture=null;return;}
|
||||
RemotePreviewStore.Capture capture=previewCapture;NativeSession source=session;previewCopying=true;
|
||||
video.capturePreview(bitmap->{
|
||||
if(session!=source||previewCapture!=capture||!previews.current(capture)){if(bitmap!=null)bitmap.recycle();return;}
|
||||
previewCopying=false;
|
||||
if(bitmap!=null){previewCapture=null;previews.save(capture,bitmap);}
|
||||
else if(++previewAttempts>=3)previewCapture=null;
|
||||
});
|
||||
}
|
||||
private View segments(String[] titles,int selected,boolean enabled,java.util.function.IntConsumer changed){
|
||||
return new SegmentedControl(ui,titles,selected,enabled,changed);
|
||||
}
|
||||
private boolean saveServer(EditText address,EditText number){
|
||||
String name=address.getText().toString().trim();int value;try{value=Integer.parseInt(number.getText().toString());}catch(NumberFormatException e){number.setError("请输入 1–65535 范围内的端口");return false;}
|
||||
if(name.length()>253||!name.matches("[A-Za-z0-9.-]+")||name.startsWith(".")||name.endsWith(".")){address.setError("填写主机名或 IPv4 地址,不含协议和路径");return false;}if(value<1||value>65535){number.setError("端口范围为 1–65535");return false;}host=name;port=value;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();return true;
|
||||
}
|
||||
private void announcements(){
|
||||
installRoot("announcements",false);LinearLayout bar=navigation("通知公告",()->home(""));
|
||||
bar.removeViewAt(bar.getChildCount()-1);announcementRefresh=ui.iconButton("refresh","刷新公告",announcementInbox::refresh);bar.addView(announcementRefresh,ui.size(44,44));
|
||||
announcementView=new AnnouncementView(ui,announcementInbox,null,this::announcementDetail,this::connectAnnouncements);
|
||||
root.addView(announcementView,new LinearLayout.LayoutParams(-1,0,1));announcementInbox.refresh();renderAnnouncements();
|
||||
}
|
||||
private void announcementDetail(AnnouncementInbox.Item item){
|
||||
installRoot("announcement-detail",false);navigation("公告详情",this::announcements);
|
||||
announcementView=new AnnouncementView(ui,announcementInbox,item,this::announcementDetail,this::connectAnnouncements);
|
||||
root.addView(announcementView,new LinearLayout.LayoutParams(-1,0,1));announcementInbox.markRead(item);
|
||||
}
|
||||
private void connectAnnouncements(){
|
||||
if(!preferences.getBoolean("networkConsent",false))consent(()->{ensureSignaling();renderAnnouncements();});
|
||||
else{stopSignaling();ensureSignaling();}
|
||||
}
|
||||
private void renderAnnouncements(){
|
||||
if(announcementBadge!=null){int unread=announcementInbox.unread;announcementBadge.setText(unread>99?"99+":String.valueOf(unread));announcementBadge.setVisibility(unread>0?View.VISIBLE:View.GONE);
|
||||
if(android.os.Build.VERSION.SDK_INT>=30)((View)announcementBadge.getParent()).setStateDescription(unread>0?unread+" 条未读":"无未读公告");}
|
||||
if(announcementRefresh!=null){boolean enabled=announcementInbox.isConnected()&&!announcementInbox.loading;announcementRefresh.setEnabled(enabled);announcementRefresh.setAlpha(enabled?1:.4f);}
|
||||
if(announcementView!=null)announcementView.render();
|
||||
}
|
||||
@Override public void announcement(JSONObject message){
|
||||
if(!preferences.getBoolean("networkConsent",false)||signalState!=1)return;
|
||||
if("announcements_changed".equals(message.optString("type")))announcementInbox.refresh();else announcementInbox.receive(message);
|
||||
}
|
||||
@Override public void announcementFailed(String requestId){announcementInbox.failed(requestId);}
|
||||
private void privacyPage(){
|
||||
LinearLayout content=formPage("privacy","隐私与授权",this::settings);LinearLayout card=section(content,"");boolean consent=preferences.getBoolean("networkConsent",false);link(card,"授权状态",consent?"已授权":"未授权",()->{});ui.divider(card);link(card,"隐私说明","",()->consent(this::privacyPage));
|
||||
footer(content,"设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用会结束当前会话。");
|
||||
if(consent)content.addView(ui.action("撤回联网授权",RED,()->new AlertDialog.Builder(this).setTitle("撤回联网授权?").setMessage("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。").setNegativeButton("取消",null).setPositiveButton("撤回",(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary("阅读并授权",()->consent(this::privacyPage)));
|
||||
}
|
||||
private void about(){
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings);aboutPages.show();
|
||||
}
|
||||
private void sendText(String value){if(session==null)return;if(value.length()>2048){toast("单次最多 2048 个字符");return;}for(char ch:value.toCharArray())if(KeyMap.ascii(ch)==0){toast("中文等文本请通过剪贴板发送,再点击粘贴");return;}for(char ch:value.toCharArray()){int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;if(shift)session.key(0x10,true);tapKey(code&0xFF);if(shift)session.key(0x10,false);}}
|
||||
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
|
||||
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
|
||||
private void selectDisplay(){
|
||||
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{if(session!=null){session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
|
||||
}
|
||||
private void clipboard(){
|
||||
if(!ready)return;
|
||||
EditText input=field("要发送到远端的文本",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE);input.setSingleLine(false);input.setMinLines(3);
|
||||
new AlertDialog.Builder(this).setTitle("剪贴板").setView(input)
|
||||
.setMessage("发送后,在远程键盘中点击“粘贴”。接收的远端文本可手动复制到本机。")
|
||||
.setNegativeButton("关闭",null).setNeutralButton("复制远端文本",(d,w)->{
|
||||
if(!remoteClipboard.isEmpty())((ClipboardManager)getSystemService(CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("CrossDesk",remoteClipboard));
|
||||
}).setPositiveButton("发送文本",(d,w)->{
|
||||
String value=input.getText().toString();
|
||||
if(value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>128*1024){Toast.makeText(this,"文本超过 128 KB",Toast.LENGTH_SHORT).show();return;}
|
||||
if(session!=null)session.clipboard(value);
|
||||
}).show();
|
||||
}
|
||||
void licenseDocument(String title,String value,Runnable back){
|
||||
installRoot("document:"+title,false);navigation(title,back);
|
||||
// Bound each selectable accessibility node while keeping every character of long notices.
|
||||
java.util.List<String> chunks=new java.util.ArrayList<>();
|
||||
for(int start=0;start<value.length();){int end=Math.min(start+4000,value.length());if(end<value.length()){int newline=value.lastIndexOf('\n',end-1);if(newline>start)end=newline+1;else if(Character.isHighSurrogate(value.charAt(end-1)))end--;}chunks.add(value.substring(start,end));start=end;}
|
||||
ListView paragraphs=new ListView(this);paragraphs.setBackgroundColor(Color.WHITE);paragraphs.setDivider(null);paragraphs.setClipToPadding(false);paragraphs.setPadding(0,dp(8),0,dp(8));
|
||||
paragraphs.setAdapter(new ArrayAdapter<String>(this,android.R.layout.simple_list_item_1,chunks){
|
||||
@Override public View getView(int position,View reuse,ViewGroup parent){
|
||||
TextView item=(TextView)super.getView(position,reuse,parent);item.setTextColor(INK);item.setTextSize(16);item.setAutoLinkMask(android.text.util.Linkify.WEB_URLS);item.setText(getItem(position));item.setTextIsSelectable(true);item.setLinksClickable(true);item.setLinkTextColor(BLUE);item.setPadding(dp(16),dp(8),dp(16),dp(8));return item;
|
||||
}
|
||||
});root.addView(paragraphs,new LinearLayout.LayoutParams(-1,0,1));
|
||||
}
|
||||
private void ensureSignaling(){
|
||||
if(!foreground||!preferences.getBoolean("networkConsent",false))return;
|
||||
if(signaling!=null&&!signaling.isClosed()&&signalingServer.equals(serverKey()))return;
|
||||
if(!announcementServer.equals(serverKey())){announcementInbox.reset();announcementServer=serverKey();}
|
||||
stopSignaling();signalState=0;signalError="";signalingServer=serverKey();
|
||||
signaling=new NativeSession(this,host,port,"","",this);
|
||||
renderSignaling();signaling.start();
|
||||
}
|
||||
private void stopSignaling(){
|
||||
announcementInbox.setConnected(false);
|
||||
if(signaling!=null)signaling.close();
|
||||
signaling=null;signalingServer="";localIdentity="";signalState=-1;signalError="";renderSignaling();
|
||||
}
|
||||
private void renderSignaling(){
|
||||
boolean consent=preferences.getBoolean("networkConsent",false);
|
||||
String label;int tint,background;
|
||||
if(!consent){label="等待隐私授权";tint=0xFFB76A00;background=0xFFFFEBD2;}
|
||||
else if(!signalError.isEmpty()){label=signalError.contains("TLS")?"证书校验失败 · 点击重试":"连接失败 · 点击重试";tint=RED;background=0xFFFFE8E6;}
|
||||
else if(signalState==1){label="✓ 已连接服务器";tint=GREEN;background=0xFFE5F4E8;}
|
||||
else{label=signalState==0?"正在连接服务器…":signalState==4?"正在重连服务器…":"未连接服务器 · 点击重试";tint=0xFFB76A00;background=0xFFFFEBD2;}
|
||||
if(signalBadge!=null){signalBadge.setText(label);signalBadge.setTextColor(tint);signalBadge.setBackground(ui.background(background,20));signalBadge.setContentDescription(signalError.isEmpty()?label:signalError+",点击重试");}
|
||||
if(identitySummary!=null)identitySummary.setText(!consent?"完成隐私授权后登记本机身份":!localIdentity.isEmpty()?"本机 ID "+localIdentity:!signalError.isEmpty()?signalError:"正在获取本机 ID…");
|
||||
}
|
||||
@Override public void signaling(int state,String deviceId){
|
||||
signalState=state;localIdentity=deviceId;signalError="";renderSignaling();
|
||||
if(preferences.getBoolean("networkConsent",false)){announcementInbox.configure(host,port,deviceId);announcementInbox.setConnected(state==1);}
|
||||
}
|
||||
@Override public void status(String value){if(session!=null&&status!=null)status.setText(value);}
|
||||
@Override public void connected(){
|
||||
// Transport readiness can be reported again after ICE changes its selected path.
|
||||
// The first success consumes pendingPassword; a duplicate must not save it again.
|
||||
if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}
|
||||
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen();
|
||||
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
|
||||
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
|
||||
}
|
||||
@Override public void ended(String reason){
|
||||
if(session!=null){end(reason);return;}
|
||||
stopSignaling();signalError=reason;renderSignaling();
|
||||
}
|
||||
@Override public void passwordRejected(){
|
||||
if(session==null)return;
|
||||
String id=connectedRemote;boolean remember=rememberPassword;
|
||||
end("");
|
||||
// Let the user replace an expired credential; never retry it automatically.
|
||||
if(foreground)passwordSheet(id,"访问密码错误,请重新输入",remember);
|
||||
}
|
||||
private void cancelVideoSettingsTimeout(){if(videoSettingsTimeout!=null)mainHandler.removeCallbacks(videoSettingsTimeout);videoSettingsTimeout=null;}
|
||||
private void showRemoteUpdate(){
|
||||
if(!ready||session==null||!foreground||isFinishing()||!preferences.getBoolean("networkConsent",false))return;
|
||||
remoteUpdateDialog=new AlertDialog.Builder(this).setTitle("升级提示").setMessage(RemoteVersionCheck.NOTICE).setPositiveButton("知道了",null).create();
|
||||
remoteUpdateDialog.setOnDismissListener(dialog->remoteUpdateDialog=null);
|
||||
remoteUpdateDialog.setCanceledOnTouchOutside(false);
|
||||
remoteUpdateDialog.show();
|
||||
}
|
||||
private void resetRemoteVersionCheck(){
|
||||
remoteVersionCheck.reset();
|
||||
if(remoteUpdateDialog!=null){remoteUpdateDialog.dismiss();remoteUpdateDialog=null;}
|
||||
}
|
||||
private void resetVideoSettings(){
|
||||
cancelVideoSettingsTimeout();
|
||||
android.util.DisplayMetrics metrics=getResources().getDisplayMetrics();
|
||||
videoSettings=new RemoteVideoSettings(preferences.getInt("videoPreference",1),Math.max(metrics.widthPixels,metrics.heightPixels));
|
||||
}
|
||||
private void updateVideoSettings(RemoteVideoSettings.Values value){
|
||||
if(!ready||session==null||!preferences.getBoolean("networkConsent",false))return;
|
||||
long request=videoSettings.begin(value);if(request<0)return;
|
||||
cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);session.videoSettings(value,request);
|
||||
videoSettingsTimeout=()->{videoSettings.expire(request);if(controls!=null)controls.videoSettings(videoSettings);};mainHandler.postDelayed(videoSettingsTimeout,5000);
|
||||
}
|
||||
@Override public void data(JSONObject message){
|
||||
if(session==null)return;
|
||||
if(message.optInt("type",-1)==12){if(!ready)return;videoSettings.receive(message);if(!videoSettings.pending)cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);return;}
|
||||
JSONObject info=message.optJSONObject("host_info");
|
||||
if(info!=null){
|
||||
hostPlatform=info.optString("platform","");JSONArray list=info.optJSONArray("displays");if(list!=null)displays=list;history.metadata(serverKey(),connectedRemote,info.optString("host_name",connectedRemote),hostPlatform);
|
||||
boolean wasSupported=videoSettings.supported;videoSettings.setSupported(info.optBoolean("supports_video_settings",false));
|
||||
if(!videoSettings.supported)cancelVideoSettingsTimeout();
|
||||
if(controls!=null)controls.videoSettings(videoSettings);
|
||||
if(videoSettings.supported&&!wasSupported)updateVideoSettings(videoSettings.selection);
|
||||
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
|
||||
}
|
||||
}
|
||||
@Override public void videoSize(int width,int height){if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);}
|
||||
@Override public void clipboard(String value){remoteClipboard=value;}
|
||||
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
|
||||
@Override public boolean dispatchKeyEvent(KeyEvent event){
|
||||
if(ready&&session!=null&&hasWindowFocus()&&!(getCurrentFocus() instanceof EditText)&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK){int code=KeyMap.windowsCode(event.getKeyCode());if(code!=0){boolean down=event.getAction()==KeyEvent.ACTION_DOWN;if(down)pressed.add(code);else pressed.remove(code);session.key(code,down);return true;}}return super.dispatchKeyEvent(event);
|
||||
}
|
||||
private void releaseKeys(){if(session!=null)for(int code:pressed)session.key(code,false);pressed.clear();}
|
||||
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
|
||||
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
||||
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
|
||||
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
||||
@Override public void onConfigurationChanged(Configuration config){super.onConfigurationChanged(config);if(controls!=null)controls.post(controls::constrainPanels);}
|
||||
@Override protected void onStart(){super.onStart();foreground=true;ensureSignaling();}
|
||||
@Override protected void onStop(){foreground=false;pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
||||
@Override protected void onDestroy(){pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.*;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
|
||||
/** Shared visual tokens matching the SwiftUI mobile screens. Values are in dp/sp. */
|
||||
final class MobileUi {
|
||||
static final int BACKGROUND=0xFFF2F2F7, INK=0xFF1C1C1E, SECONDARY=0xFF737378,
|
||||
LINE=0xFFE2E2E7, BLUE=0xFF2E7ADB, RED=0xFFFF3B30, GREEN=0xFF248A3D;
|
||||
final Context context;
|
||||
MobileUi(Context context) { this.context=context; }
|
||||
int dp(float value) { return Math.round(value*context.getResources().getDisplayMetrics().density); }
|
||||
GradientDrawable background(int color,int radius) {
|
||||
GradientDrawable shape=new GradientDrawable();shape.setColor(color);shape.setCornerRadius(dp(radius));return shape;
|
||||
}
|
||||
void card(View view,int radius) { GradientDrawable shape=background(Color.WHITE,radius);shape.setStroke(dp(.5f),LINE);view.setBackground(shape);view.setClipToOutline(true); }
|
||||
LinearLayout column() { LinearLayout v=new LinearLayout(context);v.setOrientation(LinearLayout.VERTICAL);return v; }
|
||||
LinearLayout row() { LinearLayout v=new LinearLayout(context);v.setGravity(Gravity.CENTER_VERTICAL);return v; }
|
||||
TextView text(String title,int size,boolean bold) {
|
||||
TextView v=new TextView(context);v.setText(title);v.setTextSize(size);v.setTextColor(INK);v.setIncludeFontPadding(false);
|
||||
if(bold)v.setTypeface(null,Typeface.BOLD);return v;
|
||||
}
|
||||
TextView action(String title,int color,Runnable click) {
|
||||
TextView v=text(title,16,true);v.setGravity(Gravity.CENTER);v.setTextColor(color);
|
||||
v.setMinHeight(dp(48));v.setClickable(true);v.setFocusable(true);v.setOnClickListener(ignored->click.run());
|
||||
v.setAccessibilityDelegate(new View.AccessibilityDelegate(){@Override public void onInitializeAccessibilityNodeInfo(View host,android.view.accessibility.AccessibilityNodeInfo info){super.onInitializeAccessibilityNodeInfo(host,info);info.setClassName("android.widget.Button");}});
|
||||
return v;
|
||||
}
|
||||
TextView primary(String title,Runnable click) {
|
||||
TextView v=action(title,Color.WHITE,click);
|
||||
GradientDrawable gradient=new GradientDrawable(GradientDrawable.Orientation.TL_BR,new int[]{BLUE,0xFF1F61C7});gradient.setCornerRadius(dp(12));v.setBackground(gradient);return v;
|
||||
}
|
||||
View iconButton(String icon,String label,Runnable click) {
|
||||
FrameLayout box=new FrameLayout(context);box.setBackground(background(Color.WHITE,10));
|
||||
Icon glyph=new Icon(context,icon,INK);FrameLayout.LayoutParams p=new FrameLayout.LayoutParams(dp(22),dp(22),Gravity.CENTER);box.addView(glyph,p);
|
||||
box.setContentDescription(label);box.setClickable(true);box.setFocusable(true);box.setOnClickListener(v->click.run());return box;
|
||||
}
|
||||
View icon(String name,int color) { return new Icon(context,name,color); }
|
||||
void gap(LinearLayout parent,int height){parent.addView(new View(context),new LinearLayout.LayoutParams(1,dp(height)));}
|
||||
void divider(LinearLayout parent){View line=new View(context);line.setBackgroundColor(LINE);parent.addView(line,new LinearLayout.LayoutParams(-1,dp(.5f)));}
|
||||
LinearLayout.LayoutParams size(int width,int height){return new LinearLayout.LayoutParams(width<0?width:dp(width),height<0?height:dp(height));}
|
||||
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
|
||||
static final class Icon extends View {
|
||||
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); private final String name;
|
||||
Icon(Context context,String name,int color){super(context);this.name=name;paint.setColor(color);setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);}
|
||||
@Override protected void onDraw(Canvas c){
|
||||
c.save();c.scale(getWidth()/24f,getHeight()/24f);paint.setStyle(Paint.Style.STROKE);paint.setStrokeWidth(1.7f);paint.setStrokeCap(Paint.Cap.ROUND);paint.setStrokeJoin(Paint.Join.ROUND);
|
||||
switch(name){
|
||||
case "settings":
|
||||
c.drawCircle(12,12,7,paint);c.drawCircle(12,12,3,paint);
|
||||
for(int i=0;i<8;i++){c.save();c.rotate(i*45,12,12);c.drawLine(12,2,12,5,paint);c.restore();}break;
|
||||
case "bell":
|
||||
path(c,5,17,6,14,6,9,7,6,10,4,14,4,17,6,18,9,18,14,19,17,5,17);c.drawArc(9,17,15,22,0,180,false,paint);c.drawLine(12,2,12,4,paint);break;
|
||||
case "history":
|
||||
c.drawArc(4,4,21,21,215,315,false,paint);path(c,3,4,3,10,9,10);path(c,12,7,12,13,16,15);break;
|
||||
case "back":path(c,15,4,7,12,15,20);break;
|
||||
case "refresh":c.drawArc(4,4,20,20,35,310,false,paint);path(c,20,3,20,9,14,9);break;
|
||||
case "close":path(c,6,6,18,18);path(c,18,6,6,18);break;
|
||||
case "arrow":path(c,4,12,20,12);path(c,14,6,20,12,14,18);break;
|
||||
case "keyboard":
|
||||
c.drawRoundRect(2,5,22,19,3,3,paint);for(int y=9;y<=12;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,16,17,16,paint);break;
|
||||
case "display":c.drawRoundRect(2,3,22,17,2,2,paint);path(c,12,17,12,21);path(c,7,21,17,21);break;
|
||||
case "sliders":
|
||||
for(int y=6;y<=18;y+=6){c.drawLine(3,y,21,y,paint);c.drawCircle(y==12?15:8,y,2,paint);}break;
|
||||
case "audio":path(c,3,9,7,9,12,4,12,20,7,15,3,15,3,9);c.drawArc(10,5,21,19,-65,130,false,paint);break;
|
||||
case "mouse":c.drawRoundRect(5,2,19,22,7,7,paint);path(c,12,3,12,10);break;
|
||||
case "folder":path(c,2,19,2,5,9,5,12,8,22,8,22,19,2,19);break;
|
||||
case "chart":path(c,3,3,3,21,22,21);path(c,7,17,7,13);path(c,12,17,12,8);path(c,18,17,18,4);break;
|
||||
case "lock":c.drawRoundRect(5,10,19,22,2,2,paint);c.drawArc(8,2,16,15,180,180,false,paint);c.drawPoint(12,16,paint);break;
|
||||
case "unlock":c.drawRoundRect(5,10,19,22,2,2,paint);c.drawArc(8,2,16,15,180,150,false,paint);c.drawPoint(12,16,paint);break;
|
||||
case "clipboard":c.drawRoundRect(4,4,20,22,2,2,paint);c.drawRoundRect(8,2,16,7,2,2,paint);break;
|
||||
case "eye":path(c,2,12,6,7,12,5,18,7,22,12,18,17,12,19,6,17,2,12);c.drawCircle(12,12,3,paint);break;
|
||||
default:c.drawCircle(12,12,9,paint);path(c,12,10,12,17);c.drawPoint(12,6,paint);
|
||||
}c.restore();
|
||||
}
|
||||
private void path(Canvas c,float... values){Path p=new Path();p.moveTo(values[0],values[1]);for(int i=2;i<values.length;i+=2)p.lineTo(values[i],values[i+1]);c.drawPath(p,paint);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.view.Surface;
|
||||
import org.json.JSONObject;
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
final class NativeSession implements AutoCloseable {
|
||||
static { System.loadLibrary("crossdesk_android"); }
|
||||
interface Listener {
|
||||
void status(String message);
|
||||
void connected();
|
||||
void ended(String reason);
|
||||
void data(JSONObject message);
|
||||
void videoSize(int width, int height);
|
||||
void clipboard(String text);
|
||||
void statistics(RemoteNetworkStatistics.Snapshot value);
|
||||
default void signaling(int state, String deviceId) { }
|
||||
default void announcement(JSONObject message) { }
|
||||
default void announcementFailed(String requestId) { }
|
||||
default void passwordRejected() { ended("远端密码错误"); }
|
||||
}
|
||||
// Serializes all peer creation/API calls/destruction, including across activities.
|
||||
private static final ExecutorService RTC = Executors.newSingleThreadExecutor(r -> new Thread(r, "CrossDesk RTC"));
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final Context context;
|
||||
private final Listener listener;
|
||||
private final SecretStore secrets;
|
||||
private final String host, scope;
|
||||
private volatile String remote;
|
||||
private String password, identity = "";
|
||||
private final int port;
|
||||
private long handle;
|
||||
private boolean signalReady, joining, connected, started;
|
||||
private int signalState;
|
||||
private volatile boolean closed;
|
||||
private Surface surface;
|
||||
private volatile AudioPlayer audio;
|
||||
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
|
||||
private final Runnable statisticsTick=new Runnable(){public void run(){
|
||||
if(closed)return;
|
||||
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
|
||||
if(!closed)main.postDelayed(this,1000);
|
||||
}};
|
||||
private final Runnable timeout = () -> fail(remote.isEmpty()
|
||||
? "服务器连接超时,请检查服务器地址和网络"
|
||||
: "连接超时,请检查设备 ID、服务器及远端在线状态");
|
||||
|
||||
NativeSession(Context context, String host, int port, String remote, String password, Listener listener) {
|
||||
this.context = context.getApplicationContext(); this.listener = listener;
|
||||
this.host = host; this.port = port; this.remote = remote; this.password = password;
|
||||
scope = host.toLowerCase(java.util.Locale.ROOT) + ":" + port;
|
||||
secrets = new SecretStore(this.context);
|
||||
}
|
||||
void start() {
|
||||
if (started || closed) return;
|
||||
started = true;
|
||||
main.postDelayed(timeout, 40000);
|
||||
execute(() -> {
|
||||
try {
|
||||
identity = secrets.get(scope);
|
||||
File certificates = exportTrust();
|
||||
File logs = new File(context.getNoBackupFilesDir(), "logs");
|
||||
if (!logs.isDirectory() && !logs.mkdirs()) throw new IllegalStateException("Log directory unavailable");
|
||||
handle = nCreate(this, host, port, identity, logs.getAbsolutePath(), certificates.getAbsolutePath());
|
||||
if (handle == 0) { fail("原生连接初始化失败"); return; }
|
||||
if (surface != null && surface.isValid()) nSurface(handle, surface);
|
||||
} catch (Exception error) { fail("安全存储或证书初始化失败,请重试或清除应用数据"); }
|
||||
});
|
||||
}
|
||||
/** Promote the already registered identity peer to an outgoing desktop session. */
|
||||
void connect(String remoteId, String secret) {
|
||||
execute(() -> {
|
||||
if (joining || connected) return;
|
||||
remote = remoteId;
|
||||
password = secret;
|
||||
// Serialize arming with identity callbacks, which cancel the idle
|
||||
// login timeout. Otherwise a just-completed login can cancel this
|
||||
// outgoing connection's timeout before tryJoin runs.
|
||||
main.removeCallbacks(timeout);
|
||||
main.postDelayed(timeout, 40000);
|
||||
tryJoin();
|
||||
});
|
||||
}
|
||||
boolean isClosed() { return closed; }
|
||||
private void publishSignaling() {
|
||||
int state = signalState;
|
||||
// Only expose the public device ID, never the identity's @password suffix.
|
||||
String deviceId = identity.split("@", 2)[0];
|
||||
ui(() -> listener.signaling(state, deviceId));
|
||||
}
|
||||
private File exportTrust() throws Exception {
|
||||
TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
factory.init((KeyStore) null);
|
||||
StringBuilder pem = new StringBuilder();
|
||||
for (TrustManager manager : factory.getTrustManagers()) {
|
||||
if (manager instanceof X509TrustManager trust) {
|
||||
for (X509Certificate certificate : trust.getAcceptedIssuers()) {
|
||||
pem.append("-----BEGIN CERTIFICATE-----\n")
|
||||
.append(Base64.encodeToString(certificate.getEncoded(), Base64.NO_WRAP))
|
||||
.append("\n-----END CERTIFICATE-----\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pem.length() == 0) throw new IllegalStateException("Empty Android trust store");
|
||||
File output = new File(context.getNoBackupFilesDir(), "trusted-roots.pem");
|
||||
Files.write(output.toPath(), pem.toString().getBytes(StandardCharsets.US_ASCII));
|
||||
return output;
|
||||
}
|
||||
private void execute(Runnable task) { RTC.execute(() -> { if (!closed) task.run(); }); }
|
||||
private void ui(Runnable task) { main.post(() -> { if (!closed) task.run(); }); }
|
||||
private void fail(String reason) { ui(() -> { listener.ended(reason); close(); }); }
|
||||
private void tryJoin() {
|
||||
if (handle == 0 || joining || remote.isEmpty() || !signalReady || identity.isEmpty()) return;
|
||||
joining = true;
|
||||
String base = identity.split("@", 2)[0];
|
||||
if (!nConnect(handle, base, remote, password)) fail("无法发起远程连接");
|
||||
password = "";
|
||||
}
|
||||
// Called on arbitrary native callback threads; borrowed payloads were copied by JNI.
|
||||
@SuppressWarnings("unused") private void onNativeEvent(int type, byte[] bytes) {
|
||||
execute(() -> {
|
||||
String value = new String(bytes, StandardCharsets.UTF_8);
|
||||
try {
|
||||
if (type == 3) {
|
||||
if (value.contains("@")) {
|
||||
identity = value;
|
||||
secrets.put(scope, identity);
|
||||
} else if (!identity.startsWith(value + "@")) identity = value;
|
||||
publishSignaling(); tryJoin(); return;
|
||||
}
|
||||
if (type == 6) { ui(() -> listener.clipboard(value)); return; }
|
||||
JSONObject event = new JSONObject(value);
|
||||
if (type == 1) {
|
||||
int status = event.getInt("status");
|
||||
if (!event.getBoolean("controller")) {
|
||||
signalState = status;
|
||||
signalReady = status == 1;
|
||||
if (signalReady && remote.isEmpty()) main.removeCallbacks(timeout);
|
||||
publishSignaling();
|
||||
tryJoin();
|
||||
}
|
||||
if (status == 6) fail("服务器 TLS 证书校验失败");
|
||||
else if (status == 2) fail("服务器登录失败,请检查服务器地址和网络");
|
||||
else if (status >= 3 && connected) fail("信令连接中断,请重新连接");
|
||||
else if (status == 0 || status == 4) ui(() -> listener.status("正在连接信令服务器…"));
|
||||
} else if (type == 2) {
|
||||
int status = event.getInt("status");
|
||||
if (status == 1) {
|
||||
if (connected) return; // Repeated transport readiness is the same desktop session.
|
||||
connected = true; main.removeCallbacks(timeout); nReady(handle);
|
||||
audio = new AudioPlayer();
|
||||
nControl(handle, 2, 1);
|
||||
ui(() -> { listener.connected(); if(!closed)statisticsTick.run(); });
|
||||
} else if (status == 0 || status == 2) ui(() -> listener.status("正在建立安全连接…"));
|
||||
else if (status == 6) ui(() -> { listener.passwordRejected(); close(); });
|
||||
else {
|
||||
String[] reasons = {"", "", "", "连接已断开", "连接失败", "远端已关闭连接",
|
||||
"远端密码错误", "找不到该设备 ID", "远端当前不可用"};
|
||||
fail(status >= 0 && status < reasons.length ? reasons[status] : "连接已结束");
|
||||
}
|
||||
} else if (type == 4) ui(() -> listener.data(event));
|
||||
else if (type == 5) {
|
||||
int width = event.getInt("width"), height = event.getInt("height");
|
||||
ui(() -> listener.videoSize(width, height));
|
||||
} else if (type == 7) networkStatistics.receive(new RemoteNetworkStatistics.Report(event),RemoteNetworkStatistics.now());
|
||||
else if (type == 8 && signalReady) ui(() -> listener.announcement(event));
|
||||
} catch (Exception error) {
|
||||
if (type == 3) fail("设备身份保存失败,请检查安全存储");
|
||||
}
|
||||
});
|
||||
}
|
||||
@SuppressWarnings("unused") private void onNativeAudio(byte[] pcm) {
|
||||
AudioPlayer player = audio;
|
||||
if (!closed && player != null) player.offer(pcm);
|
||||
}
|
||||
@SuppressWarnings("unused") private void onNativeVideoFrame(long id,double latency,int width,int height){
|
||||
if(!closed)networkStatistics.frame(id,latency,width,height,RemoteNetworkStatistics.now());
|
||||
}
|
||||
void videoSettings(RemoteVideoSettings.Values settings,long requestId){
|
||||
if(!settings.valid()||requestId<0||requestId>0xffffffffL)return;
|
||||
execute(()->{if(connected&&!nVideoSettings(handle,settings.quality,settings.frameRate,settings.preference,requestId))ui(()->listener.data(settings.status(requestId,false)));});
|
||||
}
|
||||
void announcementRequest(JSONObject request,String requestId){
|
||||
byte[] bytes=request.toString().getBytes(StandardCharsets.UTF_8);
|
||||
execute(()->{if(!signalReady||identity.isEmpty()||!nAnnouncementRequest(handle,bytes))ui(()->listener.announcementFailed(requestId));});
|
||||
}
|
||||
void surface(Surface value) { execute(() -> { surface = value; if (handle != 0) nSurface(handle, value); }); }
|
||||
void pointer(float x, float y, int flag, int wheel) { execute(() -> { if (connected) nPointer(handle, x, y, flag, wheel); }); }
|
||||
void key(int code, boolean down) { execute(() -> { if (connected) nKey(handle, code, down); }); }
|
||||
void control(int type, int value) {
|
||||
AudioPlayer player = audio;
|
||||
if (type == 2 && player != null) player.setEnabled(value != 0);
|
||||
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
|
||||
}
|
||||
void clipboard(String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
|
||||
execute(() -> { if (connected) nClipboard(handle, bytes); });
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; main.removeCallbacks(timeout);main.removeCallbacks(statisticsTick);
|
||||
RTC.execute(() -> {
|
||||
main.removeCallbacks(timeout);
|
||||
if (audio != null) { audio.close(); audio = null; }
|
||||
password = "";
|
||||
if (handle != 0) { nDestroy(handle); handle = 0; }
|
||||
surface = null;
|
||||
});
|
||||
}
|
||||
static native boolean nValidAppVersion(String version);
|
||||
static native boolean nHasAppUpdate(String version, byte[] releaseJson);
|
||||
private static native long nCreate(NativeSession owner, String host, int port, String identity, String logs, String roots);
|
||||
private static native boolean nConnect(long handle, String identity, String remote, String password);
|
||||
private static native void nReady(long handle);
|
||||
private static native void nPointer(long handle, float x, float y, int flag, int wheel);
|
||||
private static native void nKey(long handle, int code, boolean down);
|
||||
private static native void nControl(long handle, int type, int value);
|
||||
private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId);
|
||||
private static native boolean nAnnouncementRequest(long handle,byte[] data);
|
||||
private static native void nClipboard(long handle, byte[] data);
|
||||
private static native void nSurface(long handle, Surface surface);
|
||||
private static native void nDestroy(long handle);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.animation.PathInterpolator;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
/** Retains only the current page and, during a transition, the outgoing page. */
|
||||
final class PageHost extends FrameLayout {
|
||||
enum Motion { NONE, PUSH, POP, SESSION_IN, SESSION_OUT }
|
||||
|
||||
private FrameLayout current, outgoing;
|
||||
private Motion motion=Motion.NONE;
|
||||
private ValueAnimator animator;
|
||||
private ViewTreeObserver pendingObserver;
|
||||
private ViewTreeObserver.OnPreDrawListener pendingStart;
|
||||
private int accessibility, focusability;
|
||||
private float distance;
|
||||
private long generation;
|
||||
|
||||
PageHost(Context context){super(context);setBackgroundColor(MobileUi.BACKGROUND);}
|
||||
|
||||
void show(FrameLayout page,Motion requested){
|
||||
finish();
|
||||
FrameLayout previous=current;current=page;
|
||||
if(previous==null||requested==Motion.NONE||!ValueAnimator.areAnimatorsEnabled()||!isAttachedToWindow()){
|
||||
removeAllViews();addView(page,new LayoutParams(-1,-1));page.requestApplyInsets();return;
|
||||
}
|
||||
outgoing=previous;motion=requested;
|
||||
accessibility=page.getImportantForAccessibility();
|
||||
focusability=page.getDescendantFocusability();
|
||||
previous.clearFocus();previous.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
page.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
page.setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS);
|
||||
boolean underneath=motion==Motion.POP||motion==Motion.SESSION_IN;
|
||||
addView(page,underneath?0:getChildCount(),new LayoutParams(-1,-1));
|
||||
// SurfaceView pages stay opaque and unlayered. Fade the ordinary UI above them.
|
||||
if(motion==Motion.PUSH||motion==Motion.POP){
|
||||
previous.setLayerType(LAYER_TYPE_HARDWARE,null);page.setLayerType(LAYER_TYPE_HARDWARE,null);
|
||||
(motion==Motion.PUSH?page:previous).setElevation(8*getResources().getDisplayMetrics().density);
|
||||
}else (motion==Motion.SESSION_IN?previous:page).setLayerType(LAYER_TYPE_HARDWARE,null);
|
||||
distance=getWidth()*(getLayoutDirection()==LAYOUT_DIRECTION_RTL?-1:1);
|
||||
progress(0);page.requestApplyInsets();
|
||||
// Wait for content and insets, so the first drawn frame already has the right position.
|
||||
long transition=generation;
|
||||
pendingObserver=getViewTreeObserver();
|
||||
pendingStart=()->{
|
||||
if(transition!=generation||outgoing==null)return true;
|
||||
clearPendingStart();distance=getWidth()*(getLayoutDirection()==LAYOUT_DIRECTION_RTL?-1:1);progress(0);
|
||||
ValueAnimator next=ValueAnimator.ofFloat(0,1);animator=next;
|
||||
boolean session=motion==Motion.SESSION_IN||motion==Motion.SESSION_OUT;
|
||||
next.setDuration(session?200:350);
|
||||
next.setInterpolator(session?new PathInterpolator(.42f,0,.58f,1):new PathInterpolator(.25f,.1f,.25f,1));
|
||||
next.addUpdateListener(value->{if(animator==value)progress((float)value.getAnimatedValue());});
|
||||
next.addListener(new AnimatorListenerAdapter(){@Override public void onAnimationEnd(Animator value){if(animator==value)finish();}});
|
||||
next.start();return true;
|
||||
};
|
||||
pendingObserver.addOnPreDrawListener(pendingStart);
|
||||
}
|
||||
|
||||
private void progress(float value){
|
||||
switch(motion){
|
||||
case PUSH -> {current.setTranslationX(distance*(1-value));outgoing.setTranslationX(-distance*.3f*value);}
|
||||
case POP -> {current.setTranslationX(-distance*.3f*(1-value));outgoing.setTranslationX(distance*value);}
|
||||
case SESSION_IN -> outgoing.setAlpha(1-value);
|
||||
case SESSION_OUT -> current.setAlpha(value);
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
|
||||
void finish(){
|
||||
generation++;
|
||||
clearPendingStart();
|
||||
ValueAnimator running=animator;animator=null;if(running!=null)running.cancel();
|
||||
if(outgoing==null)return;
|
||||
reset(outgoing);removeView(outgoing);outgoing=null;
|
||||
reset(current);current.setImportantForAccessibility(accessibility);
|
||||
current.setDescendantFocusability(focusability);motion=Motion.NONE;
|
||||
}
|
||||
private void clearPendingStart(){
|
||||
if(pendingObserver!=null&&pendingObserver.isAlive()&&pendingStart!=null)pendingObserver.removeOnPreDrawListener(pendingStart);
|
||||
pendingObserver=null;pendingStart=null;
|
||||
}
|
||||
private static void reset(View page){page.setTranslationX(0);page.setAlpha(1);page.setElevation(0);page.setLayerType(LAYER_TYPE_NONE,null);}
|
||||
@Override public boolean onInterceptTouchEvent(MotionEvent event){return outgoing!=null||super.onInterceptTouchEvent(event);}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // A temporary input shield; the pages own all click actions.
|
||||
@Override public boolean onTouchEvent(MotionEvent event){return outgoing!=null||super.onTouchEvent(event);}
|
||||
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh){super.onSizeChanged(w,h,oldw,oldh);if(oldw>0&&oldh>0&&(w!=oldw||h!=oldh))finish();}
|
||||
@Override protected void onDetachedFromWindow(){finish();super.onDetachedFromWindow();}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** History and encrypted passwords are isolated by server; history never contains secrets. */
|
||||
final class RecentConnections {
|
||||
private final SharedPreferences preferences;
|
||||
private final SecretStore secrets;
|
||||
private final Context context;
|
||||
RecentConnections(Context context){this.context=context.getApplicationContext();preferences=context.getSharedPreferences("settings",Context.MODE_PRIVATE);secrets=new SecretStore(context);}
|
||||
private String key(String server){return "recentList:"+server;}
|
||||
private String credential(String server,String id){return "remote-password:"+server+":"+id;}
|
||||
JSONArray list(String server){
|
||||
try {
|
||||
if(!preferences.contains(key(server))){
|
||||
String legacy=preferences.getString("recent:"+server,"");
|
||||
if(!legacy.isEmpty())return new JSONArray().put(new JSONObject().put("id",legacy).put("name",legacy));
|
||||
}
|
||||
return new JSONArray(preferences.getString(key(server),"[]"));
|
||||
}
|
||||
catch(org.json.JSONException error){return new JSONArray();}
|
||||
}
|
||||
boolean remembersPassword(String server,String id){
|
||||
JSONArray records=list(server);
|
||||
for(int i=0;i<records.length();i++){JSONObject item=records.optJSONObject(i);if(item!=null&&id.equals(item.optString("id")))return item.optBoolean("remember");}
|
||||
return false;
|
||||
}
|
||||
String password(String server,String id)throws Exception{return secrets.get(credential(server,id));}
|
||||
boolean contains(String server,String id){JSONArray all=list(server);for(int i=0;i<all.length();i++){JSONObject item=all.optJSONObject(i);if(item!=null&&id.equals(item.optString("id")))return true;}return false;}
|
||||
void save(String server,String id,String name,String platform,boolean remember,String password)throws Exception{
|
||||
if(remember&&password.isEmpty())throw new IllegalArgumentException("Cannot remember an empty password");
|
||||
if(remember)secrets.put(credential(server,id),password);else secrets.remove(credential(server,id));
|
||||
JSONObject item=new JSONObject().put("id",id).put("name",name.isEmpty()?id:name).put("platform",platform).put("remember",remember);
|
||||
JSONArray result=new JSONArray().put(item),old=list(server);
|
||||
for(int i=0;i<old.length();i++){JSONObject entry=old.optJSONObject(i);if(entry!=null&&!id.equals(entry.optString("id"))){if(result.length()<20)result.put(entry);else RemotePreviewStore.get(context).remove(server,entry.optString("id"));}}
|
||||
preferences.edit().putString(key(server),result.toString()).apply();
|
||||
}
|
||||
void metadata(String server,String id,String name,String platform){
|
||||
JSONArray all=list(server);
|
||||
try{for(int i=0;i<all.length();i++){JSONObject item=all.optJSONObject(i);if(item!=null&&id.equals(item.optString("id"))){item.put("name",name.isEmpty()?id:name);item.put("platform",platform);}}}
|
||||
catch(org.json.JSONException ignored){return;}
|
||||
preferences.edit().putString(key(server),all.toString()).apply();
|
||||
}
|
||||
void delete(String server,String id){
|
||||
JSONArray result=new JSONArray(),old=list(server);for(int i=0;i<old.length();i++){JSONObject entry=old.optJSONObject(i);if(entry!=null&&!id.equals(entry.optString("id")))result.put(entry);}
|
||||
preferences.edit().putString(key(server),result.toString()).apply();secrets.remove(credential(server,id));
|
||||
RemotePreviewStore.get(context).remove(server,id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Monotonic, thread-safe measurements; the UI samples these once a second, as on iOS. */
|
||||
final class RemoteNetworkStatistics {
|
||||
static final class Traffic {
|
||||
final long inbound,outbound;final double loss;
|
||||
Traffic(JSONObject json){inbound=json.optLong("inbound",-1);outbound=json.optLong("outbound",-1);loss=json.optDouble("loss",Double.NaN);}
|
||||
}
|
||||
static final class Report {
|
||||
final Traffic[] traffic=new Traffic[4];final int mode;final boolean srtp;final double rtt;
|
||||
Report(JSONObject json){
|
||||
String[] keys={"video","audio","data","total"};
|
||||
for(int i=0;i<keys.length;i++){JSONObject item=json.optJSONObject(keys[i]);traffic[i]=item==null?null:new Traffic(item);}
|
||||
mode=json.optInt("mode",2);srtp=json.optBoolean("srtp");rtt=json.optDouble("rtt",Double.NaN);
|
||||
}
|
||||
}
|
||||
static final class Snapshot {
|
||||
final Report report;final int fps,width,height;final double latency,rtt;
|
||||
Snapshot(Report report,int fps,int width,int height,double latency,double rtt){this.report=report;this.fps=fps;this.width=width;this.height=height;this.latency=latency;this.rtt=rtt;}
|
||||
String mode(){return report==null?"—":report.mode==0?"P2P 直连":report.mode==1?"TURN 中继":"—";}
|
||||
String resolution(){return width>0&&height>0?width+" × "+height:"—";}
|
||||
String summary(){
|
||||
String value=mode()+(width>0&&height>0?" · "+width+"×"+height:"")+" · "+bitrate(report==null||report.traffic[3]==null?-1:report.traffic[3].inbound);
|
||||
if(report!=null&&report.traffic[0]!=null&&report.traffic[0].loss>0)value+=" · 丢包 "+loss(report.traffic[0].loss);return value;
|
||||
}
|
||||
}
|
||||
private static final class Frame {
|
||||
final double time,latency;Frame(double time,double latency){this.time=time;this.latency=latency;}
|
||||
}
|
||||
private Report report;
|
||||
private double reportTime,rttTime,averageRtt=Double.NaN;
|
||||
private long lastFrameId;
|
||||
private int width,height;
|
||||
private final ArrayDeque<Frame> frames=new ArrayDeque<>();
|
||||
static double now(){return System.nanoTime()/1_000_000_000.0;}
|
||||
synchronized void receive(Report value,double now){
|
||||
report=value;reportTime=now;
|
||||
if(!Double.isFinite(value.rtt)||value.rtt<0||value.rtt>2000)return;
|
||||
averageRtt=Double.isFinite(averageRtt)&&now-rttTime<3?averageRtt+.25*(value.rtt-averageRtt):value.rtt;rttTime=now;
|
||||
}
|
||||
synchronized void frame(long id,double latency,int width,int height,double now){
|
||||
if(id<=lastFrameId||width<=0||height<=0)return;lastFrameId=id;this.width=width;this.height=height;
|
||||
// Native code validates clock calibration before adding local decode/render waits.
|
||||
// A valid but slow frame must not disappear just because its delay exceeds 5 s.
|
||||
prune(now);frames.addLast(new Frame(now,Double.isFinite(latency)&&latency>=0?latency:Double.NaN));
|
||||
while(frames.size()>240)frames.removeFirst();
|
||||
}
|
||||
synchronized void resetVideo(){frames.clear();width=height=0;}
|
||||
synchronized Snapshot snapshot(double now){
|
||||
prune(now);double total=0;int count=0;
|
||||
for(Frame frame:frames)if(Double.isFinite(frame.latency)){total+=frame.latency;count++;}
|
||||
return new Snapshot(now-reportTime<3?report:null,frames.size(),width,height,count==0?Double.NaN:total/count,now-rttTime<3?averageRtt:Double.NaN);
|
||||
}
|
||||
private void prune(double now){while(!frames.isEmpty()&&now-frames.peekFirst().time>=1)frames.removeFirst();}
|
||||
static String bitrate(long value){return value<0?"—":value>=1_000_000?String.format(Locale.ROOT,"%.1f Mbps",value/1_000_000.0):value>=1000?String.format(Locale.ROOT,"%.0f Kbps",value/1000.0):value+" bps";}
|
||||
static String loss(double value){return Double.isFinite(value)&&value>=0?String.format(Locale.ROOT,"%.1f%%",value*100):"—";}
|
||||
static String latency(double value){return !Double.isFinite(value)||value<0?"—":value<1?"<1 ms":String.format(Locale.ROOT,"%.0f ms",value);}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.AtomicFile;
|
||||
import org.json.JSONArray;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Process-wide, serialized preview I/O. Clears invalidate even captures not yet queued for saving. */
|
||||
final class RemotePreviewStore {
|
||||
static final String OPTION="saveRemotePreviews";
|
||||
static final int WIDTH=640,HEIGHT=360;
|
||||
private static RemotePreviewStore instance;
|
||||
static synchronized RemotePreviewStore get(Context context){
|
||||
if(instance==null){
|
||||
Context app=context.getApplicationContext();
|
||||
instance=new RemotePreviewStore(new File(app.getNoBackupFilesDir(),"RecentConnectionThumbnails"),
|
||||
app.getSharedPreferences("settings",Context.MODE_PRIVATE),app.getSharedPreferences("remote-previews",Context.MODE_PRIVATE));
|
||||
instance.enforceConsent();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
static final class Capture {
|
||||
final String key;final long generation,version;
|
||||
private Capture(String key,long generation,long version){this.key=key;this.generation=generation;this.version=version;}
|
||||
}
|
||||
final File directory;
|
||||
final ExecutorService io=Executors.newSingleThreadExecutor(r->new Thread(r,"CrossDesk previews"));
|
||||
private final Handler main=new Handler(Looper.getMainLooper());
|
||||
private final SharedPreferences options,index;
|
||||
private final Set<String> files=new HashSet<>();
|
||||
private final Map<String,Long> versions=new HashMap<>();
|
||||
private final Set<Runnable> listeners=new CopyOnWriteArraySet<>();
|
||||
private long generation;
|
||||
volatile boolean cleaning;
|
||||
volatile String cleanupError="";
|
||||
RemotePreviewStore(File directory,SharedPreferences options,SharedPreferences index){
|
||||
this.directory=directory;this.options=options;this.index=index;
|
||||
for(String key:index.getStringSet("files",Collections.emptySet()))if(key.matches("[a-f0-9]{64}"))files.add(key);
|
||||
}
|
||||
boolean enabled(){return options.getBoolean("networkConsent",false)&&options.getBoolean(OPTION,false);}
|
||||
void addListener(Runnable listener){listeners.add(listener);}
|
||||
void removeListener(Runnable listener){listeners.remove(listener);}
|
||||
private void changed(){main.post(()->{for(Runnable listener:listeners)listener.run();});}
|
||||
synchronized void enforceConsent(){
|
||||
if(!options.getBoolean("networkConsent",false))options.edit().putBoolean(OPTION,false).apply();
|
||||
if(!enabled()&&!cleaning)clear();
|
||||
}
|
||||
void setEnabled(boolean enabled){
|
||||
options.edit().putBoolean(OPTION,enabled&&options.getBoolean("networkConsent",false)).apply();
|
||||
if(!enabled())clear();else changed();
|
||||
}
|
||||
synchronized Capture begin(String server,String id){
|
||||
if(!enabled()||cleaning||id.isEmpty())return null;
|
||||
String key=key(server,id);long version=versions.getOrDefault(key,0L)+1;versions.put(key,version);
|
||||
return new Capture(key,generation,version);
|
||||
}
|
||||
synchronized boolean current(Capture capture){
|
||||
return capture!=null&&enabled()&&!cleaning&&capture.generation==generation&&
|
||||
capture.version==versions.getOrDefault(capture.key,0L);
|
||||
}
|
||||
/** Takes ownership of the small bitmap; JPEG compression and writes never run on the UI thread. */
|
||||
void save(Capture capture,Bitmap bitmap){
|
||||
io.execute(()->{
|
||||
boolean saved=false;
|
||||
try{
|
||||
if(current(capture)&&bitmap.getWidth()==WIDTH&&bitmap.getHeight()==HEIGHT)saved=write(capture.key,bitmap);
|
||||
}finally{bitmap.recycle();}
|
||||
synchronized(this){
|
||||
if(!saved||!current(capture))return;
|
||||
Set<String> next=new HashSet<>(files);next.add(capture.key);
|
||||
if(!index.edit().putStringSet("files",next).commit())return;
|
||||
files.add(capture.key);
|
||||
}
|
||||
changed();
|
||||
});
|
||||
}
|
||||
private boolean write(String key,Bitmap bitmap){
|
||||
AtomicFile file=new AtomicFile(new File(directory,key+".jpg"));FileOutputStream output=null;
|
||||
try{
|
||||
ByteArrayOutputStream encoded=new ByteArrayOutputStream();
|
||||
if(!bitmap.compress(Bitmap.CompressFormat.JPEG,78,encoded))return false;
|
||||
byte[] bytes=encoded.toByteArray();
|
||||
if(!directory.isDirectory()&&!directory.mkdirs())return false;
|
||||
output=file.startWrite();output.write(bytes);
|
||||
output.getFD().sync();file.finishWrite(output);output=null;
|
||||
return Arrays.equals(bytes,file.readFully());
|
||||
}catch(IOException|RuntimeException error){if(output!=null)file.failWrite(output);return false;}
|
||||
}
|
||||
void load(String server,String id,Consumer<Bitmap> receive){
|
||||
final Capture read;
|
||||
synchronized(this){
|
||||
String key=key(server,id);
|
||||
if(!enabled()||cleaning||!files.contains(key)){main.post(()->receive.accept(null));return;}
|
||||
read=new Capture(key,generation,versions.getOrDefault(key,0L));
|
||||
}
|
||||
io.execute(()->{
|
||||
Bitmap bitmap=null;
|
||||
if(current(read)){
|
||||
File file=new File(directory,read.key+".jpg");
|
||||
if(file.isFile()&&file.length()<=2*1024*1024){
|
||||
BitmapFactory.Options bounds=new BitmapFactory.Options();bounds.inJustDecodeBounds=true;
|
||||
BitmapFactory.decodeFile(file.getAbsolutePath(),bounds);
|
||||
if(bounds.outWidth==WIDTH&&bounds.outHeight==HEIGHT)bitmap=BitmapFactory.decodeFile(file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
Bitmap image=bitmap;
|
||||
main.post(()->{
|
||||
boolean valid;synchronized(this){valid=current(read)&&files.contains(read.key);}
|
||||
if(valid)receive.accept(image);else if(image!=null)image.recycle();
|
||||
});
|
||||
});
|
||||
}
|
||||
synchronized void remove(String server,String id){
|
||||
String key=key(server,id);versions.put(key,versions.getOrDefault(key,0L)+1);files.remove(key);
|
||||
io.execute(()->{
|
||||
new AtomicFile(new File(directory,key+".jpg")).delete();
|
||||
synchronized(this){
|
||||
boolean saved=index.edit().putStringSet("files",new HashSet<>(files)).commit();
|
||||
if(!saved||new File(directory,key+".jpg").exists())cleanupError="部分预览图未能清除,请重试。";
|
||||
}
|
||||
changed();
|
||||
});changed();
|
||||
}
|
||||
synchronized void clear(){
|
||||
final long epoch=++generation;files.clear();cleaning=true;cleanupError="";changed();
|
||||
io.execute(()->{
|
||||
boolean success=index.edit().clear().commit();
|
||||
try{deleteTree(directory);}catch(IOException error){success=false;}
|
||||
synchronized(this){
|
||||
if(epoch!=generation)return;
|
||||
cleaning=false;cleanupError=success?"":"部分预览图未能清除,请重试。";
|
||||
}
|
||||
changed();
|
||||
});
|
||||
}
|
||||
private static void deleteTree(File file)throws IOException{
|
||||
if(!file.exists())return;
|
||||
if(file.isDirectory()&&!Files.isSymbolicLink(file.toPath())){
|
||||
File[] children=file.listFiles();if(children==null)throw new IOException("Cannot list previews");
|
||||
boolean failed=false;for(File child:children)try{deleteTree(child);}catch(IOException error){failed=true;}
|
||||
if(failed)throw new IOException("Cannot clear all previews");
|
||||
}
|
||||
if(!file.delete())throw new IOException("Cannot delete preview");
|
||||
}
|
||||
private static String key(String server,String id){
|
||||
try{
|
||||
byte[] bytes=new JSONArray().put(server).put(id).toString().getBytes(StandardCharsets.UTF_8);
|
||||
byte[] hash=MessageDigest.getInstance("SHA-256").digest(bytes);StringBuilder key=new StringBuilder();
|
||||
for(byte b:hash)key.append(String.format(Locale.ROOT,"%02x",b&255));return key.toString();
|
||||
}catch(Exception error){throw new IllegalStateException(error);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import org.json.JSONObject;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.function.Consumer;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
/** One check per desktop session. State and completion callbacks run on main. */
|
||||
final class RemoteVersionCheck {
|
||||
static final String NOTICE = "被控端版本过低,请升级到最新版本。";
|
||||
static final String RELEASE_URL = "https://version.crossdesk.cn/version.json";
|
||||
interface Cancellation { void cancel(); }
|
||||
interface Fetcher { Cancellation fetch(Consumer<byte[]> completion); }
|
||||
|
||||
private final Fetcher fetcher;
|
||||
private final Runnable notify;
|
||||
private boolean connected, receivedHostInfo, attempted;
|
||||
private String version = "";
|
||||
private long generation;
|
||||
private Cancellation request;
|
||||
|
||||
RemoteVersionCheck(Runnable notify) { this(ReleaseRequest::start, notify); }
|
||||
RemoteVersionCheck(Fetcher fetcher, Runnable notify) {
|
||||
this.fetcher = fetcher;
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
void connected() { connected = true; check(); }
|
||||
|
||||
void hostInfo(JSONObject info) {
|
||||
receivedHostInfo = true;
|
||||
Object value = info.opt("app_version");
|
||||
version = value instanceof String && NativeSession.nValidAppVersion((String)value)
|
||||
? (String)value : "";
|
||||
check();
|
||||
}
|
||||
|
||||
private void check() {
|
||||
if (!connected || !receivedHostInfo || attempted) return;
|
||||
attempted = true;
|
||||
// iOS treats missing/invalid peer versions as legacy, without a request.
|
||||
if (version.isEmpty()) { notify.run(); return; }
|
||||
long currentGeneration = generation;
|
||||
String currentVersion = version;
|
||||
request = fetcher.fetch(data -> {
|
||||
if (generation != currentGeneration || !connected) return;
|
||||
request = null;
|
||||
if (data != null && NativeSession.nHasAppUpdate(currentVersion, data)) notify.run();
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
++generation;
|
||||
connected = receivedHostInfo = attempted = false;
|
||||
version = "";
|
||||
if (request != null) request.cancel();
|
||||
request = null;
|
||||
}
|
||||
|
||||
private static final class ReleaseRequest implements Cancellation, Runnable {
|
||||
private static final int MAX_BYTES = 256 * 1024;
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final Consumer<byte[]> completion;
|
||||
private final Runnable timeout = () -> finish(null);
|
||||
private volatile boolean cancelled;
|
||||
private HttpsURLConnection connection;
|
||||
|
||||
private ReleaseRequest(Consumer<byte[]> completion) { this.completion = completion; }
|
||||
static Cancellation start(Consumer<byte[]> completion) {
|
||||
ReleaseRequest request = new ReleaseRequest(completion);
|
||||
request.main.postDelayed(request.timeout, 15000);
|
||||
Thread worker = new Thread(request, "CrossDesk version check");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override public void run() {
|
||||
byte[] result = null;
|
||||
HttpsURLConnection opened = null;
|
||||
try {
|
||||
// Fetch public metadata only: no peer identity, version or credentials.
|
||||
opened = (HttpsURLConnection)new URL(RELEASE_URL).openConnection();
|
||||
synchronized (this) {
|
||||
if (cancelled) return;
|
||||
connection = opened;
|
||||
}
|
||||
opened.setConnectTimeout(5000);
|
||||
opened.setReadTimeout(10000);
|
||||
opened.setUseCaches(false);
|
||||
opened.setInstanceFollowRedirects(false);
|
||||
if (opened.getResponseCode() == 200 && opened.getContentLengthLong() <= MAX_BYTES) {
|
||||
try (InputStream input = opened.getInputStream();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int count;
|
||||
while (!cancelled && (count = input.read(buffer)) != -1) {
|
||||
if (output.size() + count > MAX_BYTES) return;
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
if (!cancelled) result = output.toByteArray();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// A failed check is silent, as on iOS; it never interrupts control.
|
||||
} finally {
|
||||
if (opened != null) opened.disconnect();
|
||||
byte[] response = result;
|
||||
main.post(() -> finish(response));
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(byte[] response) {
|
||||
if (cancelled) return;
|
||||
cancel();
|
||||
completion.accept(response);
|
||||
}
|
||||
|
||||
@Override public void cancel() {
|
||||
HttpsURLConnection active;
|
||||
synchronized (this) { cancelled = true; active = connection; connection = null; }
|
||||
main.removeCallbacks(timeout);
|
||||
if (active != null) active.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Same request/acknowledgement and rollback rules as the iOS controller. */
|
||||
final class RemoteVideoSettings {
|
||||
static final String[] PREFERENCES={"帧率优先","画质优先","平衡"};
|
||||
static final String[] DETAILS={
|
||||
"优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。",
|
||||
"始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。",
|
||||
"兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。"};
|
||||
static final class Values {
|
||||
final int quality,frameRate,preference;
|
||||
Values(int quality,int frameRate,int preference){this.quality=quality;this.frameRate=frameRate;this.preference=preference;}
|
||||
boolean valid(){return quality>=0&&quality<=2&&(frameRate==30||frameRate==60)&&preference>=0&&preference<=2;}
|
||||
JSONObject status(long requestId,boolean accepted){
|
||||
try{return new JSONObject().put("type",12).put("video_settings",new JSONObject().put("quality",quality).put("frame_rate",frameRate).put("preference",preference).put("request_id",requestId).put("accepted",accepted));}
|
||||
catch(org.json.JSONException impossible){throw new IllegalStateException(impossible);}
|
||||
}
|
||||
}
|
||||
boolean supported,pending,failed;
|
||||
Values selection,applied;
|
||||
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.
|
||||
// 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);
|
||||
}
|
||||
void setSupported(boolean value){supported=value;if(!value){selection=applied;pending=false;failed=false;}}
|
||||
long begin(Values value){
|
||||
if(!supported||!value.valid())return -1;
|
||||
requestId=(requestId+1)&0xffffffffL;selection=value;pending=true;failed=false;return requestId;
|
||||
}
|
||||
void receive(Values value,long responseId,boolean accepted){
|
||||
if(!supported||!value.valid()||responseId<0||responseId>0xffffffffL||(int)(requestId-responseId)<0)return;
|
||||
if(accepted&&(int)(responseId-appliedRequestId)>=0){applied=value;appliedRequestId=responseId;}
|
||||
if(responseId!=requestId)return;
|
||||
pending=false;failed=!accepted;selection=applied;
|
||||
}
|
||||
void receive(JSONObject message){
|
||||
try{
|
||||
if(message.getInt("type")!=12)return;
|
||||
JSONObject settings=message.getJSONObject("video_settings");
|
||||
for(String key:new String[]{"quality","frame_rate","preference","request_id"}){
|
||||
Object value=settings.get(key);if(!(value instanceof Integer)&&!(value instanceof Long))return;
|
||||
}
|
||||
long quality=settings.getLong("quality"),rate=settings.getLong("frame_rate"),preference=settings.getLong("preference");
|
||||
if(quality<0||quality>2||(rate!=30&&rate!=60)||preference<0||preference>2)return;
|
||||
Object accepted=settings.opt("accepted");if(accepted!=null&&!(accepted instanceof Boolean))return;
|
||||
receive(new Values((int)quality,(int)rate,(int)preference),settings.getLong("request_id"),Boolean.TRUE.equals(accepted));
|
||||
}catch(org.json.JSONException ignored){ }
|
||||
}
|
||||
void expire(long expiredId){if(pending&&expiredId==requestId){selection=applied;pending=false;failed=true;}}
|
||||
String feedback(){return !supported?"连接建立后可调整,需被控端支持。":failed?"调整失败,请重试。":"";}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.PixelCopy;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
@android.annotation.SuppressLint("ViewConstructor") // Constructed only with a live session, never from XML.
|
||||
final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callback {
|
||||
private final SurfaceView video;
|
||||
private final Cursor cursor;
|
||||
private final NativeSession session;
|
||||
private final GestureDetector gestures;
|
||||
private int frameWidth = 16, frameHeight = 9;
|
||||
private float x = .5f, y = .5f, scrollY;
|
||||
private boolean dragging, twoFinger, relative;
|
||||
private float lastX,lastY;
|
||||
void setRelative(boolean value) { relative=value; }
|
||||
RemoteVideoView(Context context, NativeSession session) {
|
||||
super(context); this.session = session;
|
||||
setBackgroundColor(Color.BLACK);
|
||||
video = new SurfaceView(context); video.getHolder().addCallback(this);
|
||||
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
|
||||
cursor = new Cursor(context);
|
||||
addView(cursor, new LayoutParams(1, 1, android.view.Gravity.CENTER));
|
||||
gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
|
||||
@Override public boolean onDown(MotionEvent e) { point(e); return true; }
|
||||
@Override public boolean onSingleTapUp(MotionEvent e) {
|
||||
if (!twoFinger) { point(e); cursor.performClick(); } return true;
|
||||
}
|
||||
@Override public void onLongPress(MotionEvent e) { if (!twoFinger && !dragging) { point(e); click(3, 4); } }
|
||||
@Override public boolean onScroll(MotionEvent first, MotionEvent current, float dx, float dy) {
|
||||
if (twoFinger) return true;
|
||||
if (!relative && !dragging) { point(first); session.pointer(x, y, 1, 0); dragging = true; }
|
||||
point(current); return true;
|
||||
}
|
||||
});
|
||||
cursor.setOnGenericMotionListener((v, event) -> {
|
||||
point(event);
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) {
|
||||
float delta = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
|
||||
if (delta != 0) session.pointer(x, y, 7, delta > 0 ? 1 : -1);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setContentDescription("远程桌面:轻触点击,拖动移动,长按右键,双指上下滑动滚动");
|
||||
}
|
||||
void videoSize(int width, int height) { frameWidth = width; frameHeight = height; fit(); }
|
||||
static Rect previewCrop(int width,int height){
|
||||
int w=width,h=height;
|
||||
if((long)width*RemotePreviewStore.HEIGHT>(long)height*RemotePreviewStore.WIDTH)w=Math.max(1,height*RemotePreviewStore.WIDTH/RemotePreviewStore.HEIGHT);
|
||||
else h=Math.max(1,width*RemotePreviewStore.HEIGHT/RemotePreviewStore.WIDTH);
|
||||
return new Rect((width-w)/2,(height-h)/2,(width+w)/2,(height+h)/2);
|
||||
}
|
||||
void capturePreview(java.util.function.Consumer<Bitmap> completion){
|
||||
// Copy only the decoded-video Surface, excluding cursor, controls, keyboard and black margins.
|
||||
if(!isAttachedToWindow()||!video.getHolder().getSurface().isValid()){completion.accept(null);return;}
|
||||
Bitmap image=Bitmap.createBitmap(RemotePreviewStore.WIDTH,RemotePreviewStore.HEIGHT,Bitmap.Config.ARGB_8888);
|
||||
try{
|
||||
PixelCopy.request(video.getHolder().getSurface(),previewCrop(frameWidth,frameHeight),image,result->{
|
||||
if(result==PixelCopy.SUCCESS)completion.accept(image);else{image.recycle();completion.accept(null);}
|
||||
},new Handler(Looper.getMainLooper()));
|
||||
}catch(IllegalArgumentException error){image.recycle();completion.accept(null);}
|
||||
}
|
||||
private void fit() {
|
||||
if (getWidth() == 0 || getHeight() == 0) return;
|
||||
double scale = Math.min((double)getWidth()/frameWidth, (double)getHeight()/frameHeight);
|
||||
int w = Math.max(1,(int)(frameWidth*scale)), h = Math.max(1,(int)(frameHeight*scale));
|
||||
video.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER));
|
||||
cursor.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER));
|
||||
}
|
||||
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
|
||||
private void point(MotionEvent e) {
|
||||
boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE;
|
||||
float px=e.getX(),py=e.getY();
|
||||
if(touchpad){
|
||||
if(e.getActionMasked()==MotionEvent.ACTION_MOVE){
|
||||
x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,cursor.getWidth())));
|
||||
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,cursor.getHeight())));
|
||||
}
|
||||
}else{
|
||||
x=Math.max(0,Math.min(1,px/Math.max(1,cursor.getWidth())));
|
||||
y=Math.max(0,Math.min(1,py/Math.max(1,cursor.getHeight())));
|
||||
}
|
||||
lastX=px;lastY=py;
|
||||
session.pointer(x,y,0,0); cursor.invalidate();
|
||||
}
|
||||
private void click(int down, int up) { session.pointer(x,y,down,0); session.pointer(x,y,up,0); }
|
||||
private boolean touch(MotionEvent e) {
|
||||
int action = e.getActionMasked();
|
||||
if (action == MotionEvent.ACTION_DOWN) twoFinger = false;
|
||||
if (action == MotionEvent.ACTION_POINTER_DOWN) {
|
||||
if (dragging) { session.pointer(x,y,2,0); dragging = false; }
|
||||
twoFinger = true; scrollY = e.getY();
|
||||
MotionEvent cancel = MotionEvent.obtain(e); cancel.setAction(MotionEvent.ACTION_CANCEL);
|
||||
gestures.onTouchEvent(cancel); cancel.recycle();
|
||||
}
|
||||
if (twoFinger && action == MotionEvent.ACTION_MOVE) {
|
||||
float delta = e.getY() - scrollY;
|
||||
if (Math.abs(delta) > 16 * getResources().getDisplayMetrics().density) {
|
||||
session.pointer(x,y,7,delta > 0 ? 1 : -1); scrollY = e.getY();
|
||||
}
|
||||
} else if (!twoFinger) {
|
||||
if ((e.getButtonState() & MotionEvent.BUTTON_SECONDARY) != 0 && action == MotionEvent.ACTION_DOWN) {
|
||||
point(e); click(3,4); return true;
|
||||
}
|
||||
gestures.onTouchEvent(e);
|
||||
}
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
|
||||
if (dragging) session.pointer(x,y,2,0);
|
||||
dragging = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@Override public boolean performClick() { super.performClick(); return true; }
|
||||
@Override public void surfaceCreated(SurfaceHolder holder) { session.surface(holder.getSurface()); }
|
||||
@Override public void surfaceChanged(SurfaceHolder holder,int format,int width,int height) { }
|
||||
@Override public void surfaceDestroyed(SurfaceHolder holder) { session.surface(null); }
|
||||
private final class Cursor extends View {
|
||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
Cursor(Context context) { super(context); setClickable(true); setContentDescription("远程桌面触控区域"); }
|
||||
// GestureDetector calls performClick only after resolving tap vs drag/long press.
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility")
|
||||
@Override public boolean onTouchEvent(MotionEvent event) { return touch(event); }
|
||||
@Override public boolean performClick() { super.performClick(); click(1, 2); return true; }
|
||||
@Override protected void onDraw(Canvas canvas) {
|
||||
paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(3);
|
||||
paint.setColor(Color.WHITE); canvas.drawCircle(x*getWidth(),y*getHeight(),8,paint);
|
||||
paint.setColor(Color.rgb(36,117,232)); paint.setStrokeWidth(1);
|
||||
canvas.drawCircle(x*getWidth(),y*getHeight(),10,paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
|
||||
/** Server-scoped credentials encrypted with a non-exportable Android Keystore key. */
|
||||
final class SecretStore {
|
||||
private static final String ALIAS = "crossdesk.signaling.v1";
|
||||
private final SharedPreferences preferences;
|
||||
SecretStore(Context context) { preferences = context.getSharedPreferences("identities", Context.MODE_PRIVATE); }
|
||||
private SecretKey key() throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
|
||||
store.load(null);
|
||||
if (!store.containsAlias(ALIAS)) {
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
|
||||
generator.init(new KeyGenParameterSpec.Builder(ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build());
|
||||
generator.generateKey();
|
||||
}
|
||||
return (SecretKey) store.getKey(ALIAS, null);
|
||||
}
|
||||
String get(String scope) throws Exception {
|
||||
String stored = preferences.getString(scope, "");
|
||||
if (stored.isEmpty()) return "";
|
||||
String[] parts = stored.split(":", 2);
|
||||
if (parts.length != 2) throw new IllegalStateException("Invalid credential record");
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)));
|
||||
cipher.updateAAD(scope.getBytes(StandardCharsets.UTF_8));
|
||||
return new String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), StandardCharsets.UTF_8);
|
||||
}
|
||||
void remove(String scope) { preferences.edit().remove(scope).apply(); }
|
||||
void put(String scope, String credential) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key());
|
||||
cipher.updateAAD(scope.getBytes(StandardCharsets.UTF_8));
|
||||
String value = Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP) + ":" +
|
||||
Base64.encodeToString(cipher.doFinal(credential.getBytes(StandardCharsets.UTF_8)), Base64.NO_WRAP);
|
||||
if (!preferences.edit().putString(scope, value).commit()) throw new IllegalStateException("Credential save failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.animation.PathInterpolator;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/** A single sliding selection highlight, matching the iOS segmented settings pickers. */
|
||||
@android.annotation.SuppressLint("ViewConstructor") // Programmatic control with labels and a change callback.
|
||||
final class SegmentedControl extends LinearLayout {
|
||||
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF highlight=new RectF(), destination=new RectF();
|
||||
private final float radius;
|
||||
private final IntConsumer changed;
|
||||
private int selected;
|
||||
private ValueAnimator animator;
|
||||
|
||||
SegmentedControl(MobileUi ui,String[] titles,int selection,boolean enabled,IntConsumer changed){
|
||||
super(ui.context);this.changed=changed;selected=Math.max(0,Math.min(titles.length-1,selection));radius=ui.dp(6);
|
||||
setGravity(Gravity.CENTER_VERTICAL);setPadding(ui.dp(3),ui.dp(3),ui.dp(3),ui.dp(3));setBackground(ui.background(0xFFE9E9EF,8));paint.setColor(Color.WHITE);
|
||||
for(int i=0;i<titles.length;i++){
|
||||
final int index=i;TextView choice=ui.action(titles[i],MobileUi.INK,()->select(index));
|
||||
choice.setTextSize(13);choice.setMinHeight(ui.dp(30));choice.setSelected(i==selected);choice.setEnabled(enabled);choice.setAlpha(enabled?1:.45f);
|
||||
addView(choice,new LayoutParams(0,ui.dp(30),1));
|
||||
}
|
||||
}
|
||||
|
||||
private RectF selectedBounds(){View choice=getChildAt(selected);return new RectF(choice.getLeft(),choice.getTop(),choice.getRight(),choice.getBottom());}
|
||||
private void select(int index){
|
||||
if(index==selected)return;
|
||||
setSelection(index);changed.accept(index);
|
||||
}
|
||||
void setSelection(int index){
|
||||
if(index==selected)return;
|
||||
selected=index;for(int i=0;i<getChildCount();i++)getChildAt(i).setSelected(i==selected);
|
||||
cancelAnimation();destination.set(selectedBounds());
|
||||
if(!isAttachedToWindow()||!isLaidOut()||highlight.isEmpty()||!ValueAnimator.areAnimatorsEnabled()){
|
||||
highlight.set(destination);invalidate();
|
||||
}else{
|
||||
// Restart from the displayed position, including when the user reverses mid-animation.
|
||||
RectF start=new RectF(highlight),end=new RectF(destination);ValueAnimator next=ValueAnimator.ofFloat(0,1);animator=next;
|
||||
next.setDuration(200);next.setInterpolator(new PathInterpolator(.25f,.1f,.25f,1));
|
||||
next.addUpdateListener(value->{
|
||||
if(animator!=value)return;float fraction=(float)value.getAnimatedValue();
|
||||
highlight.set(start.left+(end.left-start.left)*fraction,start.top+(end.top-start.top)*fraction,start.right+(end.right-start.right)*fraction,start.bottom+(end.bottom-start.bottom)*fraction);invalidate();
|
||||
});
|
||||
next.addListener(new AnimatorListenerAdapter(){@Override public void onAnimationEnd(Animator value){if(animator==value){animator=null;highlight.set(destination);invalidate();}}});next.start();
|
||||
}
|
||||
}
|
||||
void setOptionsEnabled(boolean enabled){
|
||||
for(int i=0;i<getChildCount();i++){getChildAt(i).setEnabled(enabled);getChildAt(i).setAlpha(enabled?1:.45f);}
|
||||
if(!enabled){cancelAnimation();highlight.set(destination);invalidate();}
|
||||
}
|
||||
private void cancelAnimation(){ValueAnimator old=animator;animator=null;if(old!=null)old.cancel();}
|
||||
@Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){
|
||||
super.onLayout(changed,left,top,right,bottom);RectF bounds=selectedBounds();
|
||||
// Text below the picker can reflow without interrupting an unchanged highlight path.
|
||||
if(!destination.equals(bounds)){cancelAnimation();destination.set(bounds);highlight.set(bounds);}
|
||||
}
|
||||
@Override protected void dispatchDraw(Canvas canvas){canvas.drawRoundRect(highlight,radius,radius,paint);super.dispatchDraw(canvas);}
|
||||
@Override protected void onDetachedFromWindow(){cancelAnimation();highlight.set(destination);super.onDetachedFromWindow();}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Safe-area overlay: a draggable 54 dp orb, a three-column menu and a floating keyboard. */
|
||||
@android.annotation.SuppressLint("ViewConstructor")
|
||||
final class SessionControls extends FrameLayout {
|
||||
interface Actions {
|
||||
void disconnect(); void display(); void clipboard(); void key(int code);
|
||||
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
|
||||
void videoSettings(int field,int value);
|
||||
}
|
||||
private final MobileUi ui;
|
||||
private final Actions actions;
|
||||
private final View orb;
|
||||
private final LinearLayout waiting;
|
||||
private View shield;
|
||||
private LinearLayout menu,keyboard;
|
||||
private TextView networkLabel,videoFeedback;
|
||||
private String menuPage="controls";
|
||||
private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0);
|
||||
private RemoteVideoSettings settings=new RemoteVideoSettings(1);
|
||||
private final java.util.List<SettingRow> settingRows=new java.util.ArrayList<>();
|
||||
private TextView[][] trafficCells;
|
||||
private LinearLayout[] trafficRows;
|
||||
private TextView[] networkDetails;
|
||||
private View encryptionLock,encryptionOpen;
|
||||
private boolean muted,relative=true,shift,computerKeyboard,orbPositioned;
|
||||
private final java.util.Set<Integer> modifiers=new java.util.LinkedHashSet<>();
|
||||
SessionControls(Context context,MobileUi ui,Actions actions){
|
||||
super(context);this.ui=ui;this.actions=actions;
|
||||
// This container must remain transparent to remote touch outside its controls.
|
||||
waiting=ui.column();waiting.setMinimumWidth(ui.dp(200));waiting.setGravity(Gravity.CENTER);waiting.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));waiting.setBackground(ui.background(0xB0202024,14));
|
||||
ProgressBar progress=new ProgressBar(context);progress.setIndeterminateTintList(android.content.res.ColorStateList.valueOf(Color.WHITE));waiting.addView(progress,ui.size(28,28));ui.gap(waiting,12);TextView message=ui.text("等待远端画面…",14,false);message.setTextColor(Color.WHITE);message.setGravity(Gravity.CENTER);waiting.addView(message);addView(waiting,new LayoutParams(-2,-2,Gravity.CENTER));
|
||||
orb=ui.iconButton("display","展开远程控制菜单",()->{if(menu==null)openMenu("controls");else closeMenu();});
|
||||
ImageView brand=new ImageView(context);brand.setImageResource(R.drawable.mobile_brand);brand.setScaleType(ImageView.ScaleType.CENTER_CROP);brand.setBackground(ui.background(Color.WHITE,23));brand.setClipToOutline(true);brand.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
((FrameLayout)orb).removeAllViews();((FrameLayout)orb).addView(brand,new LayoutParams(ui.dp(46),ui.dp(46),Gravity.CENTER));
|
||||
orb.setBackground(ui.background(0xFFF5F5F7,27));orb.setElevation(ui.dp(7));addView(orb,new LayoutParams(ui.dp(54),ui.dp(54)));
|
||||
drag(orb,orb,()->{if(menu==null)openMenu("controls");else closeMenu();});
|
||||
}
|
||||
void setRelative(boolean value){relative=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
|
||||
void setMuted(boolean value){muted=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
|
||||
void videoSize(int width,int height){waiting.setVisibility(GONE);}
|
||||
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
|
||||
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
||||
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
|
||||
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
|
||||
private void openMenu(String page){
|
||||
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
|
||||
menu=ui.column();menu.setPadding(ui.dp(12),ui.dp(12),ui.dp(12),ui.dp(12));ui.card(menu,18);menu.setElevation(ui.dp(12));
|
||||
LinearLayout header=ui.row();
|
||||
if(!page.equals("controls"))header.addView(ui.iconButton("back","返回控制栏",()->openMenu("controls")),ui.size(28,32));
|
||||
LinearLayout titles=ui.column();TextView title=ui.text(page.equals("controls")?"● 已连接":page.equals("video")?"画面设置":"网络状态",14,true);titles.addView(title);
|
||||
if(page.equals("controls")){ui.gap(titles,3);networkLabel=ui.text(network.summary(),10,false);networkLabel.setFontFeatureSettings("tnum");networkLabel.setTextColor(SECONDARY);titles.addView(networkLabel);}header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭控制栏",this::closeMenu),ui.size(30,32));menu.addView(header);ui.gap(menu,9);
|
||||
if(page.equals("video")){videoFeedback=ui.text("",12,false);videoFeedback.setTextColor(SECONDARY);videoFeedback.setPadding(0,0,0,ui.dp(9));menu.addView(videoFeedback);}
|
||||
ScrollView scroll=new ScrollView(getContext());menu.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout body=ui.column();scroll.addView(body);
|
||||
if(page.equals("network")){
|
||||
// Reserve the scrollbar's width plus a gap before right-aligned statistics.
|
||||
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
|
||||
}
|
||||
if(page.equals("controls")){
|
||||
String[] labels={"键盘","显示器","画面设置",muted?"静音":"声音",relative?"相对鼠标":"绝对鼠标","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
|
||||
String[] icons={"keyboard","display","sliders","audio","mouse","folder","chart","lock","clipboard"};
|
||||
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,()->{},()->openMenu("network"),()->{},actions::clipboard};
|
||||
for(int row=0;row<3;row++){LinearLayout line=ui.row();for(int col=0;col<3;col++){int index=row*3+col;LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
|
||||
if(index==5||index==7){control.setEnabled(false);control.setAlpha(.38f);control.setContentDescription(labels[index]+",暂不支持");}
|
||||
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
|
||||
}body.addView(line);if(row<2)ui.gap(body,7);}
|
||||
}else if(page.equals("network")){
|
||||
networkPage(body);
|
||||
}else{
|
||||
settingRows.add(new SettingRow(body,0,"画面质量",new String[]{"低","中","高"},new int[]{0,1,2}));ui.gap(body,12);
|
||||
settingRows.add(new SettingRow(body,1,"画面采集帧率",new String[]{"30 fps","60 fps"},new int[]{30,60}));ui.gap(body,12);
|
||||
settingRows.add(new SettingRow(body,2,"画面偏好",RemoteVideoSettings.PREFERENCES,new int[]{0,1,2}));renderVideoSettings();
|
||||
}
|
||||
ui.gap(menu,9);TextView disconnect=ui.action("断开连接",Color.WHITE,()->{closeMenu();closeKeyboard();actions.disconnect();});disconnect.setMinHeight(ui.dp(34));disconnect.setTextSize(14);disconnect.setBackground(ui.background(RED,8));menu.addView(disconnect,ui.size(-1,34));
|
||||
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
|
||||
}
|
||||
private final class SettingRow {
|
||||
final int field;final int[] values;final SegmentedControl group;
|
||||
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){
|
||||
this.field=field;this.values=values;
|
||||
TextView label=ui.text(title,12,false);label.setTextColor(SECONDARY);body.addView(label);ui.gap(body,5);
|
||||
group=new SegmentedControl(ui,labels,selectionIndex(),settings.supported,index->actions.videoSettings(field,values[index]));
|
||||
group.setPadding(ui.dp(2),ui.dp(2),ui.dp(2),ui.dp(2));body.addView(group);
|
||||
for(int i=0;i<labels.length;i++){
|
||||
TextView button=(TextView)group.getChildAt(i);button.setTextSize(12);button.setMinHeight(ui.dp(28));button.setSingleLine();button.setContentDescription(title+","+labels[i]);
|
||||
button.setLayoutParams(new LinearLayout.LayoutParams(0,ui.dp(28),1));
|
||||
}
|
||||
}
|
||||
int selectionIndex(){int selected=field==0?settings.selection.quality:field==1?settings.selection.frameRate:settings.selection.preference;
|
||||
for(int i=0;i<values.length;i++)if(values[i]==selected)return i;return 0;
|
||||
}
|
||||
void render(){group.setSelection(selectionIndex());group.setOptionsEnabled(settings.supported);}
|
||||
}
|
||||
private void renderVideoSettings(){
|
||||
if(videoFeedback!=null){String feedback=settings.feedback();videoFeedback.setText(feedback);videoFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);}
|
||||
for(SettingRow row:settingRows)row.render();
|
||||
}
|
||||
private void networkPage(LinearLayout body){
|
||||
trafficCells=new TextView[4][3];trafficRows=new LinearLayout[4];String[] names={"视频","音频","数据","合计"};
|
||||
LinearLayout heading=trafficRow(body,"",new TextView[3],false);((TextView)heading.getChildAt(1)).setText("接收");((TextView)heading.getChildAt(2)).setText("发送");((TextView)heading.getChildAt(3)).setText("丢包率");
|
||||
for(int i=0;i<heading.getChildCount();i++)((TextView)heading.getChildAt(i)).setTextColor(SECONDARY);
|
||||
for(int i=0;i<4;i++)trafficRows[i]=trafficRow(body,names[i],trafficCells[i],i==3);
|
||||
ui.gap(body,10);ui.divider(body);ui.gap(body,10);
|
||||
String[] titles={"帧率","分辨率","画面延时","连接延时(RTT)","连接方式","媒体加密"};networkDetails=new TextView[titles.length];
|
||||
for(int i=0;i<titles.length;i++){LinearLayout row=ui.row();TextView title=ui.text(titles[i],12,false);title.setTextColor(SECONDARY);row.addView(title,new LinearLayout.LayoutParams(0,-2,1));
|
||||
if(i==5){encryptionLock=ui.icon("lock",GREEN);encryptionOpen=ui.icon("unlock",SECONDARY);for(View icon:new View[]{encryptionLock,encryptionOpen}){LinearLayout.LayoutParams p=ui.size(14,14);p.rightMargin=ui.dp(4);row.addView(icon,p);}}
|
||||
TextView value=ui.text("—",12,false);value.setFontFeatureSettings("tnum");row.addView(value);networkDetails[i]=value;body.addView(row);if(i<titles.length-1)ui.gap(body,7);}
|
||||
renderNetwork();
|
||||
}
|
||||
private LinearLayout trafficRow(LinearLayout body,String title,TextView[] cells,boolean total){
|
||||
LinearLayout row=ui.row();row.setPadding(ui.dp(4),ui.dp(5),ui.dp(4),ui.dp(5));body.addView(row);if(total)row.setBackground(ui.background(0x122E7ADB,5));
|
||||
TextView label=ui.text(title,11,total);row.addView(label,ui.size(28,-2));
|
||||
for(int i=0;i<3;i++){TextView cell=ui.text("—",11,total);cell.setGravity(Gravity.END);cell.setSingleLine();cell.setFontFeatureSettings("tnum");cell.setAutoSizeTextTypeUniformWithConfiguration(8,11,1,android.util.TypedValue.COMPLEX_UNIT_SP);
|
||||
LinearLayout.LayoutParams p=i==2?ui.size(46,16):new LinearLayout.LayoutParams(0,ui.dp(16),1);p.leftMargin=ui.dp(4);row.addView(cell,p);cells[i]=cell;}
|
||||
return row;
|
||||
}
|
||||
private void renderNetwork(){
|
||||
if(networkLabel!=null)networkLabel.setText(network.summary());
|
||||
if(trafficCells==null)return;
|
||||
String[] names={"视频","音频","数据","合计"};
|
||||
for(int i=0;i<4;i++){
|
||||
RemoteNetworkStatistics.Traffic traffic=network.report==null?null:network.report.traffic[i];
|
||||
String in=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.inbound),out=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.outbound),loss=RemoteNetworkStatistics.loss(traffic==null?Double.NaN:traffic.loss);
|
||||
trafficCells[i][0].setText(in);trafficCells[i][1].setText(out);trafficCells[i][2].setText(loss);trafficRows[i].setContentDescription(names[i]+",接收 "+in+",发送 "+out+",丢包率 "+loss);
|
||||
}
|
||||
String[] values={network.fps+" FPS",network.resolution(),RemoteNetworkStatistics.latency(network.latency),RemoteNetworkStatistics.latency(network.rtt),network.mode(),network.report==null?"—":network.report.srtp?"SRTP 已启用":"未启用"};
|
||||
for(int i=0;i<values.length;i++)networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
|
||||
encryptionLock.setVisibility(network.report!=null&&network.report.srtp?VISIBLE:GONE);encryptionOpen.setVisibility(network.report!=null&&!network.report.srtp?VISIBLE:GONE);
|
||||
}
|
||||
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
|
||||
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
|
||||
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
|
||||
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;}}
|
||||
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
|
||||
private void showKeyboard(){
|
||||
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
|
||||
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
|
||||
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
|
||||
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keyboard.addView(line);ui.gap(keyboard,3);}
|
||||
int width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));addView(keyboard,new LayoutParams(width,-2));keyboard.post(()->{if(keyboard!=null){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);constrainPanels();}});
|
||||
}
|
||||
private void keyboardKey(String value){
|
||||
switch(value){
|
||||
case "⇧":shift=!shift;showKeyboard();return;
|
||||
case "电脑":computerKeyboard=true;showKeyboard();return;
|
||||
case "ABC":computerKeyboard=false;showKeyboard();return;
|
||||
case "剪贴板":actions.clipboard();return;
|
||||
case "复制":actions.shortcut(0x43);return;case "粘贴":actions.shortcut(0x56);return;case "全选":actions.shortcut(0x41);return;
|
||||
case "空格":sendKey(32);return;case "⌫":sendKey(8);return;case "Enter":sendKey(13);return;case "Esc":sendKey(27);return;case "Tab":sendKey(9);return;
|
||||
case "↑":sendKey(38);return;case "↓":sendKey(40);return;case "←":sendKey(37);return;case "→":sendKey(39);return;
|
||||
// Latch locally; the next key sends a complete chord without leaving remote modifiers down.
|
||||
case "Ctrl":toggleModifier(17);return;case "Alt":toggleModifier(18);return;case "Win":toggleModifier(91);return;
|
||||
default:if(value.matches("F[1-6]")){sendKey(111+Integer.parseInt(value.substring(1)));return;}if(!modifiers.isEmpty())sendKey(KeyMap.ascii(value.charAt(0))&255);else actions.text(shift?value.toUpperCase(java.util.Locale.ROOT):value);
|
||||
}
|
||||
}
|
||||
private void toggleModifier(int code){if(!modifiers.add(code))modifiers.remove(code);showKeyboard();}
|
||||
private void sendKey(int code){if(modifiers.isEmpty())actions.key(code);else{actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());modifiers.clear();showKeyboard();}}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // Tap dispatches performClick; dragging is an additional action.
|
||||
private void drag(View handle,View target,Runnable tap){
|
||||
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
|
||||
public boolean onTouch(View v,MotionEvent event){
|
||||
switch(event.getActionMasked()){
|
||||
case MotionEvent.ACTION_DOWN:startX=event.getRawX();startY=event.getRawY();originX=target.getX();originY=target.getY();moved=false;return true;
|
||||
case MotionEvent.ACTION_MOVE:float dx=event.getRawX()-startX,dy=event.getRawY()-startY;if(Math.hypot(dx,dy)>ui.dp(4))moved=true;if(moved){if(target==orb)closeMenu();target.setX(bound(originX+dx,ui.dp(8),getWidth()-target.getWidth()-ui.dp(8)));target.setY(bound(originY+dy,ui.dp(8),getHeight()-target.getHeight()-ui.dp(8)));}return true;
|
||||
case MotionEvent.ACTION_UP:if(!moved)v.performClick();return true;
|
||||
case MotionEvent.ACTION_CANCEL:return true;
|
||||
default:return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
void constrainPanels(){
|
||||
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
||||
if(menu!=null)requestLayout();
|
||||
if(keyboard!=null){ViewGroup.LayoutParams p=keyboard.getLayoutParams();p.width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));keyboard.setLayoutParams(p);keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-p.width-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}
|
||||
}
|
||||
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||
super.onMeasure(widthSpec,heightSpec);
|
||||
if(menu!=null){
|
||||
// Use this traversal's viewport, including rotation/inset changes, before drawing.
|
||||
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
|
||||
if(p.width!=width||p.height!=height){p.width=width;p.height=height;super.onMeasure(widthSpec,heightSpec);}
|
||||
}
|
||||
}
|
||||
@Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){
|
||||
super.onLayout(changed,left,top,right,bottom);
|
||||
if(!orbPositioned){orb.setX(Math.max(ui.dp(12),getWidth()-ui.dp(66)));orb.setY(ui.dp(17));orbPositioned=true;}
|
||||
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
||||
// New/rebuilt menus have real dimensions now; position them in the same frame.
|
||||
positionMenu();
|
||||
}
|
||||
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
|
||||
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,6 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white" />
|
||||
<foreground>
|
||||
<inset android:drawable="@drawable/mobile_brand" android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#2E7ADB</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
<item name="android:windowBackground">#F2F2F7</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1 @@
|
||||
plugins { id 'com.android.application' version '9.1.0' apply false }
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
distributionSha256Sum=17f277867f6914d61b1aa02efab1ba7bb439ad652ca485cd8ca6842fccec6e43
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,24 @@
|
||||
CrossDesk 开源软件权利说明 / Open-source software rights
|
||||
|
||||
CrossDesk 按 GNU GPL 第 3 版发布,MiniRTC 按 GNU LGPL 第 3 版发布。
|
||||
其他组件适用各自列明的许可证。使用、复制、修改及再分发这些组件的权利,
|
||||
以相应许可证为准;本应用的普通使用条款不限制开源许可证授予你的权利。
|
||||
许可证中的保证、责任限制及其他条件仍然适用。
|
||||
|
||||
“本版本源码与构建说明”提供与发行版本对应的源码入口、子模块信息、
|
||||
依赖配方、补丁及构建说明。可以修改相关库后重新构建和链接应用。
|
||||
在自己的 Android 设备上运行修改版时,可以使用自己的 Android 签名密钥,
|
||||
并按构建文档生成和安装 APK。
|
||||
|
||||
CrossDesk is distributed under GNU GPL version 3; MiniRTC is distributed
|
||||
under GNU LGPL version 3. Other components are governed by their listed
|
||||
licenses. Your rights to use, copy, modify and redistribute those components
|
||||
are governed by the applicable licenses. The application's ordinary terms
|
||||
of use do not restrict rights granted by those open-source licenses.
|
||||
The licenses' warranty, liability and other provisions continue to apply.
|
||||
|
||||
The version-specific source and build links identify the application source,
|
||||
submodules, dependency recipes, patches and build instructions. You can
|
||||
modify the libraries and rebuild and relink the application. Running a
|
||||
modified build on your own Android device can use your own Android signing
|
||||
key. Build and install the APK as described in the build guide.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <minirtc.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <remote_action.h>
|
||||
|
||||
namespace crossdesk::android_controller {
|
||||
inline std::string VideoSettingsMessage(int quality, int frame_rate, int preference,
|
||||
uint32_t request_id) {
|
||||
RemoteAction action{};
|
||||
action.type = video_settings;
|
||||
action.vs = {quality, frame_rate, preference, request_id, false};
|
||||
return action.to_json();
|
||||
}
|
||||
|
||||
inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, TraversalMode mode) {
|
||||
const auto traffic = [](const MiniRtcInboundStats& in, const MiniRtcOutboundStats& out) {
|
||||
return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}};
|
||||
};
|
||||
return {{"rtt", stats.rtt_ms}, {"mode", mode}, {"srtp", stats.srtp_active},
|
||||
{"video", traffic(stats.video_inbound_stats, stats.video_outbound_stats)},
|
||||
{"audio", traffic(stats.audio_inbound_stats, stats.audio_outbound_stats)},
|
||||
{"data", traffic(stats.data_inbound_stats, stats.data_outbound_stats)},
|
||||
{"total", traffic(stats.total_inbound_stats, stats.total_outbound_stats)}};
|
||||
}
|
||||
} // namespace crossdesk::android_controller
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk::android_controller {
|
||||
// Rendering may wait for Surface buffers. Never hold up the decoder or build
|
||||
// a FIFO of obsolete pictures: keep at most one pending decoded frame.
|
||||
template <typename Frame>
|
||||
class LatestFrameWorker {
|
||||
public:
|
||||
explicit LatestFrameWorker(std::function<void(Frame)> render)
|
||||
: render_(std::move(render)), thread_([this] { Run(); }) {}
|
||||
~LatestFrameWorker() { Stop(); }
|
||||
LatestFrameWorker(const LatestFrameWorker&) = delete;
|
||||
LatestFrameWorker& operator=(const LatestFrameWorker&) = delete;
|
||||
|
||||
bool Submit(Frame frame) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopped_) return false;
|
||||
pending_ = std::move(frame);
|
||||
changed_.notify_one();
|
||||
return true;
|
||||
}
|
||||
void Clear() {
|
||||
std::lock_guard lock(mutex_);
|
||||
pending_.reset();
|
||||
}
|
||||
// Called by the session owner, before destroying the peer or Surface.
|
||||
void Stop() {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopped_ = true;
|
||||
pending_.reset();
|
||||
}
|
||||
changed_.notify_one();
|
||||
if (thread_.joinable()) thread_.join();
|
||||
}
|
||||
|
||||
private:
|
||||
void Run() {
|
||||
for (;;) {
|
||||
std::unique_lock lock(mutex_);
|
||||
changed_.wait(lock, [this] { return stopped_ || pending_.has_value(); });
|
||||
if (stopped_) return;
|
||||
Frame frame = std::move(*pending_);
|
||||
pending_.reset();
|
||||
lock.unlock();
|
||||
render_(std::move(frame));
|
||||
}
|
||||
}
|
||||
const std::function<void(Frame)> render_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable changed_;
|
||||
std::optional<Frame> pending_;
|
||||
bool stopped_ = false;
|
||||
std::thread thread_;
|
||||
};
|
||||
} // namespace crossdesk::android_controller
|
||||
@@ -0,0 +1,382 @@
|
||||
#include <jni.h>
|
||||
#include <android/native_window_jni.h>
|
||||
#include <libyuv.h>
|
||||
#include <minirtc.h>
|
||||
#include <log.h>
|
||||
#include <remote_action.h>
|
||||
#include <app_version.h>
|
||||
#include <stream_names.h>
|
||||
#include <display_stream_id.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
#include "controller_protocol.h"
|
||||
#include "latest_frame_worker.h"
|
||||
#include "video_picture.h"
|
||||
#include "video_timing.h"
|
||||
|
||||
namespace {
|
||||
using namespace crossdesk;
|
||||
using nlohmann::json;
|
||||
JavaVM* vm = nullptr;
|
||||
struct Environment {
|
||||
JNIEnv* env = nullptr;
|
||||
bool attached = false;
|
||||
Environment() {
|
||||
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||
attached = vm->AttachCurrentThread(&env, nullptr) == JNI_OK;
|
||||
}
|
||||
}
|
||||
~Environment() { if (attached) vm->DetachCurrentThread(); }
|
||||
};
|
||||
std::string String(JNIEnv* env, jstring value) {
|
||||
if (!value) return {};
|
||||
const char* bytes = env->GetStringUTFChars(value, nullptr);
|
||||
std::string result = bytes ? bytes : "";
|
||||
if (bytes) env->ReleaseStringUTFChars(value, bytes);
|
||||
return result;
|
||||
}
|
||||
struct Session;
|
||||
using android_controller::VideoPicture;
|
||||
void RenderVideo(Session* s, VideoPicture picture);
|
||||
struct CallbackContext { Session* owner; bool controller; };
|
||||
struct Session {
|
||||
jobject owner = nullptr;
|
||||
jmethodID event_method, audio_method, frame_method;
|
||||
PeerPtr* identity = nullptr;
|
||||
PeerPtr* controller = nullptr;
|
||||
CallbackContext identity_context{this, false}, controller_context{this, true};
|
||||
std::string host, log_path, identity_login, controller_login, remote;
|
||||
int port;
|
||||
std::atomic<bool> stopping{false};
|
||||
std::atomic<int> display{0};
|
||||
std::atomic<bool> surface_ready{false};
|
||||
std::atomic<uint64_t> video_generation{0};
|
||||
std::mutex render_mutex;
|
||||
ANativeWindow* window = nullptr;
|
||||
uint32_t width = 0, height = 0;
|
||||
uint64_t frame_id = 0;
|
||||
std::set<int> pressed_keys;
|
||||
unsigned pressed_buttons = 0;
|
||||
float pointer_x = .5f, pointer_y = .5f;
|
||||
android_controller::LatestFrameWorker<VideoPicture> renderer{
|
||||
[this](VideoPicture picture) { RenderVideo(this, std::move(picture)); }};
|
||||
|
||||
void Event(int type, const char* data, size_t size) {
|
||||
if (stopping || size > 1024 * 1024) return;
|
||||
Environment scope;
|
||||
if (!scope.env) return;
|
||||
jbyteArray bytes = scope.env->NewByteArray(static_cast<jsize>(size));
|
||||
if (!bytes) return;
|
||||
scope.env->SetByteArrayRegion(bytes, 0, size, reinterpret_cast<const jbyte*>(data));
|
||||
scope.env->CallVoidMethod(owner, event_method, type, bytes);
|
||||
scope.env->DeleteLocalRef(bytes);
|
||||
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
|
||||
}
|
||||
void Event(int type, const json& value) {
|
||||
const std::string data = value.dump(); Event(type, data.data(), data.size());
|
||||
}
|
||||
void Send(const char* stream, const std::string& data) {
|
||||
if (controller && !stopping && !data.empty())
|
||||
SendReliableDataFrame(controller, data.data(), data.size(), stream);
|
||||
}
|
||||
void Surface(JNIEnv* env, jobject surface) {
|
||||
std::lock_guard<std::mutex> lock(render_mutex);
|
||||
++video_generation;
|
||||
renderer.Clear();
|
||||
if (window) ANativeWindow_release(window);
|
||||
window = surface ? ANativeWindow_fromSurface(env, surface) : nullptr;
|
||||
surface_ready = window != nullptr;
|
||||
width = height = 0;
|
||||
}
|
||||
~Session() {
|
||||
// API calls and destruction share the Java RTC executor. Release any
|
||||
// delivered input even when queued Java key-up events were cancelled.
|
||||
if (controller) {
|
||||
for (int key : pressed_keys) {
|
||||
RemoteAction action{}; action.type = keyboard;
|
||||
action.k = {static_cast<size_t>(key), 0, false, key_up};
|
||||
Send(kKeyboardStream, action.to_json());
|
||||
}
|
||||
for (int button = 0; button < 3; ++button) {
|
||||
if (!(pressed_buttons & (1u << button))) continue;
|
||||
RemoteAction action{}; action.type = mouse;
|
||||
action.m = {pointer_x, pointer_y, 0, static_cast<MouseFlag>(2 + button * 2)};
|
||||
Send(kMouseStream, action.to_json());
|
||||
}
|
||||
}
|
||||
stopping = true;
|
||||
renderer.Stop();
|
||||
if (controller) {
|
||||
LeaveConnection(controller, remote.c_str());
|
||||
DestroyPeer(&controller);
|
||||
}
|
||||
if (identity) DestroyPeer(&identity);
|
||||
Environment scope;
|
||||
Surface(scope.env, nullptr);
|
||||
if (scope.env) scope.env->DeleteGlobalRef(owner);
|
||||
}
|
||||
};
|
||||
|
||||
void Signal(SignalStatus status, const char*, size_t, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(user);
|
||||
ctx->owner->Event(1, {{"controller", ctx->controller}, {"status", status}});
|
||||
}
|
||||
void SignalMessage(const char* data, size_t size, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(user);
|
||||
if (ctx->controller || !data || !size || size > 1024 * 1024) return;
|
||||
auto message = json::parse(data, data + size, nullptr, false);
|
||||
if (!message.is_object() || !message.contains("type") || !message["type"].is_string()) return;
|
||||
const auto type = message["type"].get<std::string>();
|
||||
if (type == "announcements" || type == "announcements_changed") ctx->owner->Event(8, data, size);
|
||||
}
|
||||
void Connection(ConnectionStatus status, const char*, size_t, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(user);
|
||||
if (ctx->controller) ctx->owner->Event(2, {{"status", status}});
|
||||
}
|
||||
void Network(const char* id, size_t size, TraversalMode mode,
|
||||
const MiniRtcNetTrafficStats* stats, const char*, size_t, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(user);
|
||||
if (!ctx->controller && mode == UnknownMode && id && size) {
|
||||
ctx->owner->Event(3, id, size);
|
||||
} else if (ctx->controller && stats) {
|
||||
ctx->owner->Event(7, android_controller::NetworkReport(*stats, mode));
|
||||
}
|
||||
}
|
||||
void Data(const char* data, size_t size, const char*, size_t,
|
||||
const char* stream, size_t stream_size, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(user);
|
||||
if (!ctx->controller || !data || !stream || size > kMaxClipboardBytes) return;
|
||||
const std::string name(stream, stream_size);
|
||||
if (name == kControlStream) {
|
||||
RemoteAction action{};
|
||||
if (action.from_json(std::string(data, size))) {
|
||||
const auto message = action.to_json();
|
||||
ctx->owner->Event(4, message.data(), message.size());
|
||||
FreeRemoteAction(action);
|
||||
}
|
||||
}
|
||||
if (name == kClipboardStream) ctx->owner->Event(6, data, size);
|
||||
}
|
||||
void Audio(const char* data, size_t size, const char*, size_t,
|
||||
const char*, size_t, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(user);
|
||||
auto* s = ctx->owner;
|
||||
if (!ctx->controller || s->stopping || !data || !size || size > 23040) return;
|
||||
Environment scope;
|
||||
if (!scope.env) return;
|
||||
auto bytes = scope.env->NewByteArray(size);
|
||||
if (!bytes) return;
|
||||
scope.env->SetByteArrayRegion(bytes, 0, size, reinterpret_cast<const jbyte*>(data));
|
||||
scope.env->CallVoidMethod(s->owner, s->audio_method, bytes);
|
||||
scope.env->DeleteLocalRef(bytes);
|
||||
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
|
||||
}
|
||||
void Video(const MiniRtcVideoFrame* frame, const char*, size_t,
|
||||
const char* stream, size_t stream_size, void* user) {
|
||||
auto* ctx = static_cast<CallbackContext*>(user);
|
||||
auto* s = ctx->owner;
|
||||
const auto generation = s->video_generation.load();
|
||||
if (!ctx->controller || s->stopping || !s->surface_ready || !frame || !stream ||
|
||||
std::string(stream, stream_size) != MakeDisplayStreamId(s->display.load())) return;
|
||||
auto picture = VideoPicture::RetainOrCopy(*frame, generation);
|
||||
if (picture) s->renderer.Submit(std::move(*picture));
|
||||
}
|
||||
void RenderVideo(Session* s, VideoPicture picture) {
|
||||
const auto w = picture.width, h = picture.height;
|
||||
std::lock_guard<std::mutex> 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;
|
||||
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<int>(w) && buffer.height == static_cast<int>(h) &&
|
||||
libyuv::NV12ToABGR(picture.Y(), picture.YStride(), picture.UV(), picture.UVStride(),
|
||||
static_cast<uint8_t*>(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(
|
||||
picture.captured_us, picture.received_us, now);
|
||||
Environment scope;
|
||||
if (scope.env) {
|
||||
scope.env->CallVoidMethod(s->owner, s->frame_method, static_cast<jlong>(++s->frame_id), latency, static_cast<jint>(w), static_cast<jint>(h));
|
||||
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
|
||||
}
|
||||
}
|
||||
Params Parameters(Session* s, bool controller) {
|
||||
Params p{};
|
||||
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.native_video_output = controller;
|
||||
p.turn_mode = TurnAutoUdpTcp;
|
||||
p.enable_srtp = true;
|
||||
p.video_content_type = VideoContentType::ScreenContent;
|
||||
p.video_quality = QualityMedium;
|
||||
p.video_frame_rate = 30;
|
||||
p.video_degradation_preference = VideoDegradationPreference::Balanced;
|
||||
p.on_signal_status = Signal; p.on_connection_status = Connection;
|
||||
p.on_signal_message = SignalMessage;
|
||||
p.on_net_status_report = Network; p.on_receive_data_buffer = Data;
|
||||
p.on_receive_video_frame = Video; p.on_receive_audio_buffer = Audio;
|
||||
p.user_id = (controller ? s->controller_login : s->identity_login).c_str();
|
||||
p.user_data = controller ? &s->controller_context : &s->identity_context;
|
||||
return p;
|
||||
}
|
||||
Session* From(jlong handle) { return reinterpret_cast<Session*>(handle); }
|
||||
} // namespace
|
||||
|
||||
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* java_vm, void*) {
|
||||
vm = 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)(
|
||||
JNIEnv* env, jclass, jstring version) {
|
||||
return version && env->GetStringUTFLength(version) < 64 &&
|
||||
crossdesk::IsValidAppVersion(String(env, version));
|
||||
}
|
||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nHasAppUpdate)(
|
||||
JNIEnv* env, jclass, jstring version, jbyteArray release_json) {
|
||||
if (!release_json) return false;
|
||||
const auto size = env->GetArrayLength(release_json);
|
||||
if (size <= 0 || size > 256 * 1024) return false;
|
||||
std::string data(size, '\0');
|
||||
env->GetByteArrayRegion(release_json, 0, size, reinterpret_cast<jbyte*>(data.data()));
|
||||
if (env->ExceptionCheck()) return false;
|
||||
const auto latest = crossdesk::ParseVersionInfoJSON(data);
|
||||
return latest && crossdesk::AvailableAppUpdate(String(env, version), *latest).has_value();
|
||||
}
|
||||
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)(
|
||||
JNIEnv* env, jclass, jobject owner, jstring host, jint port, jstring identity,
|
||||
jstring log_path, jstring certificates) {
|
||||
try {
|
||||
auto s = std::make_unique<Session>();
|
||||
s->owner = env->NewGlobalRef(owner);
|
||||
auto cls = env->GetObjectClass(owner);
|
||||
s->event_method = env->GetMethodID(cls, "onNativeEvent", "(I[B)V");
|
||||
s->audio_method = env->GetMethodID(cls, "onNativeAudio", "([B)V");
|
||||
s->frame_method = env->GetMethodID(cls, "onNativeVideoFrame", "(JDII)V");
|
||||
env->DeleteLocalRef(cls);
|
||||
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
|
||||
// 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);
|
||||
if (bundle.empty() || setenv("SSL_CERT_FILE", bundle.c_str(), 1) != 0)
|
||||
throw std::runtime_error("Unable to configure Android trust bundle");
|
||||
// MiniRTC's diagnostic log includes login identities. Keep credentials
|
||||
// solely in Keystore-backed storage; do not persist its session logging.
|
||||
minirtc::InitLogger(s->log_path);
|
||||
minirtc::get_logger()->set_level(spdlog::level::off);
|
||||
auto params = Parameters(s.get(), false);
|
||||
s->identity = CreatePeer(¶ms);
|
||||
if (!s->identity || Init(s->identity) != 0) return 0;
|
||||
return reinterpret_cast<jlong>(s.release());
|
||||
} catch (const std::exception&) { return 0; }
|
||||
}
|
||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
|
||||
JNIEnv* env, jclass, jlong handle, jstring identity, jstring remote, jstring password) {
|
||||
auto* s = From(handle);
|
||||
if (!s || s->controller) return false;
|
||||
s->controller_login = "C-" + String(env, identity);
|
||||
s->remote = String(env, remote);
|
||||
auto params = Parameters(s, true);
|
||||
s->controller = CreatePeer(¶ms);
|
||||
if (!s->controller) return false;
|
||||
AddAudioStream(s->controller, kAudioStream);
|
||||
AddDataStream(s->controller, kDataStream, false);
|
||||
for (const char* name : {kMouseStream, kKeyboardStream, kControlStream,
|
||||
kClipboardStream}) AddDataStream(s->controller, name, true);
|
||||
if (Init(s->controller) != 0) return false;
|
||||
const auto join = s->remote + "@" + String(env, password);
|
||||
return JoinConnection(s->controller, join.c_str()) == 0;
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
|
||||
auto* s = From(handle); if (!s || !s->controller) return;
|
||||
const std::string info = R"({"type":"client_info","version":"android-native","platform":"android"})";
|
||||
SendSignalMessage(s->controller, info.data(), info.size());
|
||||
auto action = MakeHostInformation("CrossDesk Android", {}, false, "0.1.0", HostPlatform::Android);
|
||||
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
|
||||
JNIEnv*, jclass, jlong handle, jfloat x, jfloat y, jint flag, jint wheel) {
|
||||
auto* s = From(handle); if (!s || flag < 0 || flag > 8) return;
|
||||
RemoteAction action{}; action.type = mouse;
|
||||
action.m = {std::clamp(x, 0.f, 1.f), std::clamp(y, 0.f, 1.f), wheel, static_cast<MouseFlag>(flag)};
|
||||
s->pointer_x = action.m.x; s->pointer_y = action.m.y;
|
||||
if (flag >= 1 && flag <= 6) {
|
||||
const unsigned bit = 1u << ((flag - 1) / 2);
|
||||
if (flag & 1) s->pressed_buttons |= bit; else s->pressed_buttons &= ~bit;
|
||||
}
|
||||
s->Send(kMouseStream, action.to_json());
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nKey)(JNIEnv*, jclass, jlong handle, jint key, jboolean down) {
|
||||
auto* s = From(handle); if (!s) return;
|
||||
RemoteAction action{}; action.type = keyboard;
|
||||
action.k = {static_cast<size_t>(key), 0, false, down ? key_down : key_up};
|
||||
if (down) s->pressed_keys.insert(key); else s->pressed_keys.erase(key);
|
||||
s->Send(kKeyboardStream, action.to_json());
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nControl)(
|
||||
JNIEnv*, jclass, jlong handle, jint type, jint value) {
|
||||
auto* s = From(handle); if (!s) return;
|
||||
RemoteAction action{};
|
||||
if (type == display_id && value >= 0 && value < 8) {
|
||||
{ std::lock_guard<std::mutex> lock(s->render_mutex); s->display = value; ++s->video_generation; s->renderer.Clear(); 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;
|
||||
} else return;
|
||||
s->Send(kControlStream, action.to_json());
|
||||
}
|
||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nVideoSettings)(
|
||||
JNIEnv*, jclass, jlong handle, jint quality, jint rate, jint preference, jlong request_id) {
|
||||
auto* s = From(handle);
|
||||
if (!s || !s->controller || s->stopping || request_id < 0 || request_id > UINT32_MAX) return false;
|
||||
const auto data = android_controller::VideoSettingsMessage(quality, rate, preference, static_cast<uint32_t>(request_id));
|
||||
if (data.empty()) return false;
|
||||
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
||||
}
|
||||
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nAnnouncementRequest)(
|
||||
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
||||
auto* s = From(handle);
|
||||
if (!s || !s->identity || s->stopping || !data) return false;
|
||||
const auto size = env->GetArrayLength(data);
|
||||
if (size <= 0 || size > 4096) return false;
|
||||
std::string bytes(size, '\0');
|
||||
env->GetByteArrayRegion(data, 0, size, reinterpret_cast<jbyte*>(bytes.data()));
|
||||
if (env->ExceptionCheck()) return false;
|
||||
auto message = json::parse(bytes, nullptr, false);
|
||||
if (!message.is_object() || !message.contains("type") ||
|
||||
!message["type"].is_string() || message["type"] != "announcements_list") return false;
|
||||
return SendSignalMessage(s->identity, bytes.data(), bytes.size()) == 0;
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nClipboard)(
|
||||
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
|
||||
auto* s = From(handle); if (!s || !data) return;
|
||||
const auto size = env->GetArrayLength(data);
|
||||
if (!size || size > static_cast<int>(kMaxClipboardBytes)) return;
|
||||
std::string bytes(size, '\0');
|
||||
env->GetByteArrayRegion(data, 0, size, reinterpret_cast<jbyte*>(bytes.data()));
|
||||
s->Send(kClipboardStream, bytes);
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nSurface)(JNIEnv* env, jclass, jlong handle, jobject surface) {
|
||||
if (auto* s = From(handle)) s->Surface(env, surface);
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nDestroy)(JNIEnv*, jclass, jlong handle) { delete From(handle); }
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "controller_protocol.h"
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
int main() {
|
||||
using namespace crossdesk;
|
||||
for (int quality = -1; quality <= 3; ++quality)
|
||||
for (int rate : {0, 24, 30, 60, 120})
|
||||
for (int preference = -1; preference <= 3; ++preference) {
|
||||
const auto message = android_controller::VideoSettingsMessage(quality, rate, preference, UINT32_MAX);
|
||||
const bool valid = quality >= 0 && quality <= 2 && (rate == 30 || rate == 60) && preference >= 0 && preference <= 2;
|
||||
assert(message.empty() != valid);
|
||||
if (!valid) continue;
|
||||
// Decode the actual Android outgoing message with the desktop's wire parser.
|
||||
RemoteAction desktop{};
|
||||
assert(desktop.from_json(message) && desktop.type == video_settings);
|
||||
assert(desktop.vs.quality == quality && desktop.vs.frame_rate == rate && desktop.vs.preference == preference);
|
||||
assert(desktop.vs.request_id == UINT32_MAX && !desktop.vs.accepted);
|
||||
desktop.type = video_settings_status; desktop.vs.accepted = true;
|
||||
RemoteAction reply{}; assert(reply.from_json(desktop.to_json()));
|
||||
assert(reply.type == video_settings_status && reply.vs.accepted && reply.vs.request_id == UINT32_MAX);
|
||||
}
|
||||
MiniRtcNetTrafficStats stats{};
|
||||
stats.video_inbound_stats.bitrate = 8'000'000; stats.video_inbound_stats.loss_rate = .02f;
|
||||
stats.audio_inbound_stats.bitrate = 64'000; stats.audio_outbound_stats.bitrate = 128'000;
|
||||
stats.data_inbound_stats.bitrate = 2000; stats.data_outbound_stats.bitrate = 3000;
|
||||
stats.total_inbound_stats.bitrate = UINT32_MAX; stats.total_outbound_stats.bitrate = 131'000;
|
||||
stats.total_inbound_stats.loss_rate = 1.2f; stats.rtt_ms = 40; stats.srtp_active = true;
|
||||
auto json = android_controller::NetworkReport(stats, Relay);
|
||||
assert(json.at("mode") == 1 && json.at("srtp") == true && json.at("rtt") == 40);
|
||||
assert(json.at("video").at("inbound") == 8'000'000 && json.at("audio").at("outbound") == 128'000);
|
||||
assert(json.at("data").at("inbound") == 2000 && json.at("data").at("outbound") == 3000);
|
||||
assert(json.at("total").at("inbound").get<uint32_t>() == UINT32_MAX);
|
||||
assert(std::abs(json.at("total").at("loss").get<double>() - 1.2) < .001);
|
||||
stats.rtt_ms = -1; stats.srtp_active = false;
|
||||
json = android_controller::NetworkReport(stats, UnknownMode);
|
||||
assert(json.at("mode") == 2 && json.at("srtp") == false && json.at("rtt") == -1);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "latest_frame_worker.h"
|
||||
#include "video_timing.h"
|
||||
#include "video_picture.h"
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
int main() {
|
||||
using namespace crossdesk::android_controller;
|
||||
using namespace std::chrono_literals;
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 1'050'000) == 50);
|
||||
// Reproduce a valid frame delayed in the local decode/Surface queue.
|
||||
// The old submission-time five-second cutoff hid both of these samples.
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 7'000'000) == 6000);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 31'000'000) == 30000);
|
||||
assert(VideoLatencyMilliseconds(0, 1'020'000, 7'000'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'030'000, 1'020'000, 7'000'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 6'000'001, 7'000'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, 1'010'000) < 0);
|
||||
assert(VideoLatencyMilliseconds(1'000'000, 1'020'000, -1) < 0);
|
||||
|
||||
struct Owner { int references = 1; uint8_t pixels[12]{}; } owner;
|
||||
MiniRtcNativeVideoFrame native{};
|
||||
native.struct_size = sizeof(native); native.type = MiniRtcNativeVideoFrameCpuNv12;
|
||||
native.width = native.height = 2; native.owner = &owner;
|
||||
native.retain = [](void* p) { ++static_cast<Owner*>(p)->references; };
|
||||
native.release = [](void* p) { --static_cast<Owner*>(p)->references; };
|
||||
native.payload.cpu_nv12 = {owner.pixels, owner.pixels + 8, 4, 4};
|
||||
MiniRtcVideoFrame source{}; source.width = source.height = 2;
|
||||
source.native_frame = &native; source.captured_timestamp = 100; source.received_timestamp = 120;
|
||||
{
|
||||
auto retained = VideoPicture::RetainOrCopy(source, 3);
|
||||
assert(retained && owner.references == 2 && retained->pixels.empty());
|
||||
assert(retained->Y() == owner.pixels && retained->UV() == owner.pixels + 8);
|
||||
assert(retained->YStride() == 4 && retained->UVStride() == 4);
|
||||
assert(retained->captured_us == 100 && retained->received_us == 120 && retained->generation == 3);
|
||||
auto moved = std::move(*retained); retained.reset(); assert(owner.references == 2);
|
||||
}
|
||||
assert(owner.references == 1);
|
||||
uint8_t borrowed[6]{1, 2, 3, 4, 5, 6};
|
||||
source.native_frame = nullptr; source.data = reinterpret_cast<const char*>(borrowed); source.size = 6;
|
||||
auto copied = VideoPicture::RetainOrCopy(source, 4); borrowed[0] = 0;
|
||||
assert(copied && copied->Y()[0] == 1 && copied->UV()[0] == 5 && copied->YStride() == 2);
|
||||
source.size = 5; assert(!VideoPicture::RetainOrCopy(source, 4));
|
||||
source.width = 3; assert(!VideoPicture::RetainOrCopy(source, 4));
|
||||
|
||||
std::promise<void> started, release, finished;
|
||||
auto released = release.get_future();
|
||||
std::vector<int> rendered;
|
||||
LatestFrameWorker<std::unique_ptr<int>> worker([&](std::unique_ptr<int> value) {
|
||||
rendered.push_back(*value);
|
||||
if (*value == 1) { started.set_value(); released.wait(); }
|
||||
if (*value == 4) finished.set_value();
|
||||
});
|
||||
assert(worker.Submit(std::make_unique<int>(1)));
|
||||
assert(started.get_future().wait_for(2s) == std::future_status::ready);
|
||||
// A blocked Surface must not block frame submission; obsolete frames 2/3
|
||||
// must not be displayed after it recovers, even with move-only ownership.
|
||||
assert(worker.Submit(std::make_unique<int>(2)));
|
||||
assert(worker.Submit(std::make_unique<int>(3)));
|
||||
assert(worker.Submit(std::make_unique<int>(4)));
|
||||
release.set_value();
|
||||
assert(finished.get_future().wait_for(2s) == std::future_status::ready);
|
||||
worker.Stop();
|
||||
assert((rendered == std::vector<int>{1, 4}));
|
||||
assert(!worker.Submit(std::make_unique<int>(5)));
|
||||
worker.Stop();
|
||||
|
||||
std::promise<void> first_started, first_release;
|
||||
auto first_released = first_release.get_future();
|
||||
int count = 0;
|
||||
LatestFrameWorker<int> cleared([&](int value) {
|
||||
++count; assert(value == 1); first_started.set_value(); first_released.wait();
|
||||
});
|
||||
cleared.Submit(1);
|
||||
assert(first_started.get_future().wait_for(2s) == std::future_status::ready);
|
||||
cleared.Submit(2);
|
||||
cleared.Clear(); // Surface replacement/display switch discards pending pixels.
|
||||
first_release.set_value();
|
||||
cleared.Stop();
|
||||
assert(count == 1);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <minirtc.h>
|
||||
#include <climits>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::android_controller {
|
||||
struct VideoPicture {
|
||||
uint32_t width, height;
|
||||
uint64_t captured_us, received_us, generation;
|
||||
std::vector<uint8_t> pixels;
|
||||
std::shared_ptr<void> native_owner;
|
||||
MiniRtcCpuNv12Frame planes{};
|
||||
|
||||
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; }
|
||||
int YStride() const { return native_owner ? planes.y_stride : width; }
|
||||
int UVStride() const { return native_owner ? planes.uv_stride : width; }
|
||||
|
||||
static std::optional<VideoPicture> RetainOrCopy(const MiniRtcVideoFrame& frame,
|
||||
uint64_t generation) {
|
||||
const auto w = frame.width, h = frame.height;
|
||||
if (!w || !h || w > 8192 || h > 8192 || (w & 1) || (h & 1) ||
|
||||
static_cast<uint64_t>(w) * h > 33554432) return std::nullopt;
|
||||
VideoPicture result{w, h, frame.captured_timestamp, frame.received_timestamp,
|
||||
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 == MiniRtcNativeVideoFrameCpuNv12 &&
|
||||
native->retain && native->release) {
|
||||
const auto& planes = native->payload.cpu_nv12;
|
||||
if (planes.y_plane && planes.uv_plane && planes.y_stride >= w &&
|
||||
planes.uv_stride >= w && planes.y_stride <= INT_MAX && planes.uv_stride <= INT_MAX) {
|
||||
native->retain(native->owner);
|
||||
result.native_owner = std::shared_ptr<void>(native->owner, native->release);
|
||||
result.planes = planes;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
const size_t size = size_t(w) * h * 3 / 2;
|
||||
if (frame.data && frame.size >= size) {
|
||||
result.pixels.resize(size);
|
||||
std::memcpy(result.pixels.data(), frame.data, size);
|
||||
} else if (valid_native && native->copy_to_nv12) {
|
||||
result.pixels.resize(size);
|
||||
if (native->copy_to_nv12(native->owner, result.pixels.data(), size) != 0)
|
||||
return std::nullopt;
|
||||
} else {
|
||||
return std::nullopt;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
} // namespace crossdesk::android_controller
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk::android_controller {
|
||||
inline double VideoLatencyMilliseconds(uint64_t captured_us,
|
||||
uint64_t received_us,
|
||||
int64_t submitted_us) {
|
||||
// MiniRTC calibrates capture time when the frame is reassembled. Validate
|
||||
// that mapping at reception, not after decoding/Surface waits: a real local
|
||||
// backlog over five seconds must remain visible in the latency measurement.
|
||||
if (!captured_us || received_us < captured_us || submitted_us <= 0 ||
|
||||
received_us > static_cast<uint64_t>(submitted_us) ||
|
||||
received_us - captured_us > 5'000'000)
|
||||
return -1;
|
||||
return (static_cast<uint64_t>(submitted_us) - captured_us) / 1000.0;
|
||||
}
|
||||
} // namespace crossdesk::android_controller
|
||||
@@ -0,0 +1,16 @@
|
||||
set_project("crossdesk_android")
|
||||
set_languages("c++17")
|
||||
add_rules("mode.release", "mode.debug")
|
||||
set_policy("package.precompiled", false)
|
||||
add_cxflags("-fPIC")
|
||||
includes("../../../deps/submodules/minirtc")
|
||||
|
||||
target("crossdesk_android")
|
||||
set_kind("shared")
|
||||
add_deps("minirtc")
|
||||
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")
|
||||
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")
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the native controller in isolation from desktop and iOS caches."""
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
ANDROID = Path(__file__).resolve().parents[1]
|
||||
ROOT = ANDROID.parents[1]
|
||||
SOURCE = ROOT / 'deps/submodules/minirtc'
|
||||
CACHE = ANDROID / '.native'
|
||||
PACKAGE_REVISION = 'eda39de3fb99b420c168f1ab9ab2d1791e11b662'
|
||||
|
||||
|
||||
def main():
|
||||
sdk = Path(os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT')
|
||||
or Path.home() / 'Library/Android/sdk')
|
||||
ndk = sdk / 'ndk/28.2.13676358'
|
||||
if not ndk.is_dir():
|
||||
raise SystemExit('Install Android NDK 28.2.13676358 using SDK Manager.')
|
||||
bundled = Path.home() / '.cache/crossdesk/toolchains/xmake-3.1.1/xmake'
|
||||
xmake = os.environ.get('XMAKE_BIN') or (str(bundled) if bundled.is_file() else shutil.which('xmake'))
|
||||
if not xmake:
|
||||
raise SystemExit('xmake is required; install it or set XMAKE_BIN.')
|
||||
version = subprocess.check_output([xmake, '--version'], text=True)
|
||||
if not re.search(r'xmake v3\.1\.1(?:\+|[,\s])', version):
|
||||
raise SystemExit('Use Xmake 3.1.1 (set XMAKE_BIN to its executable).')
|
||||
if not (SOURCE / 'xmake.lua').is_file():
|
||||
raise SystemExit('Initialize the MiniRTC submodule before building.')
|
||||
env = os.environ.copy()
|
||||
env['XMAKE_PKG_INSTALLDIR'] = str(CACHE / 'packages')
|
||||
env['XMAKE_GLOBALDIR'] = str(CACHE / 'global')
|
||||
env['ANDROID_NDK_HOME'] = str(ndk)
|
||||
env['ANDROID_NDK_ROOT'] = str(ndk)
|
||||
env['NO_COLOR'] = '1'
|
||||
work = ANDROID / 'native'
|
||||
repository = CACHE / 'global/.xmake/repositories/xmake-repo'
|
||||
if not (repository / '.git').is_dir():
|
||||
subprocess.run([xmake, 'repo', '--update'], cwd=work, env=env, check=True)
|
||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
|
||||
if revision != PACKAGE_REVISION:
|
||||
subprocess.run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, check=True)
|
||||
subprocess.run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, check=True)
|
||||
subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a',
|
||||
'-m', 'release', '--ndk=' + str(ndk), '--ndk_sdkver=26',
|
||||
'--runtimes=c++_static', '--policies=package.precompiled:n',
|
||||
'-o', str(CACHE / 'build')], cwd=work, env=env, check=True)
|
||||
subprocess.run([xmake, '-P', str(work), '-y', 'crossdesk_android'], cwd=work, env=env, check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
with (CACHE / 'build.lock').open('w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package existing reviewed notices for the Android runtime dependency set."""
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from urllib.parse import quote
|
||||
|
||||
ANDROID = Path(__file__).resolve().parents[1]
|
||||
ROOT = ANDROID.parents[1]
|
||||
COMPONENTS = {
|
||||
'asio', 'concurrentqueue', 'dav1d', 'glib', 'inih', 'nlohmann_json',
|
||||
'kcp', 'libdatachannel', 'libffi', 'libnice', 'libsrtp', 'libyuv',
|
||||
'minirtc', 'openfec', 'openssl3', 'libopus', 'pcre2', 'plog',
|
||||
'proxy-libintl', 'spdlog', 'svt-av1', 'usrsctp', 'webrtc', 'websocketpp', 'zlib',
|
||||
}
|
||||
|
||||
|
||||
def git(*args, cwd=ROOT):
|
||||
return subprocess.check_output(['git', *args], cwd=cwd, text=True).strip()
|
||||
|
||||
|
||||
def write_pages(output, catalog, additional, revision, minirtc):
|
||||
"""Small navigation catalog and separate, offline-selectable license documents."""
|
||||
source = f'https://github.com/kunkundi/crossdesk/tree/{revision}'
|
||||
mini_source = f'https://github.com/kunkundi/minirtc/tree/{minirtc}'
|
||||
components = []
|
||||
for entry in catalog['components']:
|
||||
if entry['id'] not in COMPONENTS | {'crossdesk'}:
|
||||
continue
|
||||
component = dict(entry)
|
||||
if component['id'] == 'crossdesk':
|
||||
component['sourceURL'] = source
|
||||
component['documents'] = [
|
||||
{'title': 'LICENSE', 'text': (ROOT / 'LICENSE').read_text()},
|
||||
{'title': 'apps/android/licenses/OPEN_SOURCE_NOTICE.txt',
|
||||
'text': (ANDROID / 'licenses/OPEN_SOURCE_NOTICE.txt').read_text()},
|
||||
]
|
||||
if component['id'] in {'minirtc', 'inih', 'webrtc'}:
|
||||
component.update(version=minirtc, sourceURL=mini_source)
|
||||
if '/thirdparty/' in component.get('buildSourceURL', ''):
|
||||
recipe = component['buildSourceURL'].split('/thirdparty/', 1)[1]
|
||||
component['buildSourceURL'] = f'{mini_source}/thirdparty/{recipe}'
|
||||
components.append(component)
|
||||
components += additional
|
||||
# Ship the actual build instructions and recipes used by this local build. The Android
|
||||
# directory may not exist at the public base commit of an unpublished checkout.
|
||||
bundled = [
|
||||
('构建说明', ANDROID / 'README.md'),
|
||||
('原生构建配方', ANDROID / 'native/xmake.lua'),
|
||||
('OpenSSL 构建配方', ROOT / 'deps/submodules/minirtc/thirdparty/openssl/xmake.lua'),
|
||||
('MiniRTC 构建配方', ROOT / 'deps/submodules/minirtc/xmake.lua'),
|
||||
('原生构建脚本', ANDROID / 'scripts/build_native.py'),
|
||||
('SVT-AV1 构建配方', ROOT / 'deps/submodules/minirtc/thirdparty/svt-av1/xmake.lua'),
|
||||
]
|
||||
build_documents = []
|
||||
for index, (title, path) in enumerate(bundled):
|
||||
asset = f'about/build/{index}.txt'
|
||||
target = output / asset
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(path.read_text(), encoding='utf-8')
|
||||
build_documents.append({'title': title, 'asset': asset})
|
||||
for component in components:
|
||||
if component['id'] in {'minirtc', 'glib', 'libnice', 'openfec', 'openh264', 'openssl3', 'svt-av1'}:
|
||||
component['buildDocuments'] = build_documents[1:]
|
||||
documents = []
|
||||
for index, document in enumerate(component['documents']):
|
||||
asset = f"about/licenses/{component['id']}/{index}.txt"
|
||||
target = output / asset
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(document['text'], encoding='utf-8')
|
||||
documents.append({'title': document['title'], 'asset': asset,
|
||||
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
|
||||
component['documents'] = documents
|
||||
(output / 'ThirdPartyLicenses.json').write_text(json.dumps(
|
||||
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
tags = sorted(tag for tag in git('tag', '--points-at', 'HEAD').splitlines() if tag.startswith('v'))
|
||||
metadata = {
|
||||
'schemaVersion': 1, 'revision': revision, 'tag': tags[0] if tags else None,
|
||||
'isModified': bool(git('status', '--porcelain', '--untracked-files=normal', '--ignore-submodules=none')),
|
||||
'sourceURL': source,
|
||||
'buildInstructionsURL': f'https://github.com/kunkundi/crossdesk/blob/{quote(revision, safe="")}/apps/android/README.md',
|
||||
'buildDocuments': build_documents, 'miniRTCRevision': minirtc,
|
||||
}
|
||||
(output / 'SourceMetadata.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
|
||||
|
||||
def main():
|
||||
output = ANDROID / 'app/build/generated/notices'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
catalog = json.loads((ROOT / 'apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json').read_text())
|
||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
|
||||
minirtc = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT / 'deps/submodules/minirtc', text=True).strip()
|
||||
parts = [
|
||||
'CrossDesk Android — open-source notices\n',
|
||||
'CrossDesk: GPL-3.0-only; MiniRTC: LGPL-3.0-only. Other components retain their own licenses.\n'
|
||||
'Open-source licenses govern use, modification and redistribution; their warranty limitations apply.\n'
|
||||
'You can rebuild/relink modified libraries and install an APK signed with your own key.\n',
|
||||
f'Application base revision: {revision}\nMiniRTC base revision: {minirtc}\n'
|
||||
'This development build may contain local changes in CrossDesk and the MiniRTC submodule.\n'
|
||||
'Source: https://github.com/kunkundi/crossdesk\n'
|
||||
'Build: apps/android/README.md; native source: deps/submodules/minirtc\n',
|
||||
(ROOT / 'LICENSE').read_text(),
|
||||
]
|
||||
for component in catalog['components']:
|
||||
if component['id'] not in COMPONENTS:
|
||||
continue
|
||||
parts.append(f"\n{component['name']} — {component['license']}\n{component['sourceURL']}\n")
|
||||
for document in component['documents']:
|
||||
parts.extend([document['title'], document['text']])
|
||||
additional = json.loads((ANDROID / 'licenses/additional-notices.json').read_text())
|
||||
for component in additional:
|
||||
parts.append(component['name']+' '+component['version'])
|
||||
for document in component['documents']:
|
||||
parts.extend([document['title'], document['text']])
|
||||
(output / 'THIRD_PARTY_NOTICES.txt').write_text('\n\n'.join(parts), encoding='utf-8')
|
||||
write_pages(output, catalog, additional, revision, minirtc)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Android's native packets against the shared desktop wire parser."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ANDROID = Path(__file__).resolve().parents[1]
|
||||
ROOT = ANDROID.parents[1]
|
||||
headers = list((ANDROID / '.native/packages/n/nlohmann_json').glob('*/**/include/nlohmann/json.hpp'))
|
||||
if not headers:
|
||||
raise SystemExit('Build the Android app first to populate its pinned native dependencies.')
|
||||
with tempfile.TemporaryDirectory(prefix='crossdesk-android-protocol-') as temporary:
|
||||
binary = Path(temporary) / 'controller-protocol-test'
|
||||
subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17',
|
||||
'-I' + str(headers[0].parents[1]),
|
||||
'-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(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'),
|
||||
'-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')
|
||||
print('PASS: long video latency, invalid clocks, latest-frame rendering and shutdown')
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def verify(path):
|
||||
with zipfile.ZipFile(path) as archive, open(path, 'rb') as raw:
|
||||
libraries = [i for i in archive.infolist() if i.filename.endswith('.so')]
|
||||
if [i.filename for i in libraries] != ['lib/arm64-v8a/libcrossdesk_android.so']:
|
||||
raise ValueError('APK must contain exactly the arm64 native controller library')
|
||||
library = libraries[0]
|
||||
data = archive.read(library)
|
||||
if data[:6] != b'\x7fELF\x02\x01' or struct.unpack_from('<H', data, 18)[0] != 183:
|
||||
raise ValueError('Native library is not little-endian ELF64 AArch64')
|
||||
offset = struct.unpack_from('<Q', data, 32)[0]
|
||||
size, count = struct.unpack_from('<HH', data, 54)
|
||||
segments = [struct.unpack_from('<IIQQQQQQ', data, offset + i * size) for i in range(count)]
|
||||
loads = [s for s in segments if s[0] == 1]
|
||||
if not loads or any(s[7] < 16384 or (s[2] - s[3]) % 16384 for s in loads):
|
||||
raise ValueError('ELF PT_LOAD segments must be aligned to at least 16 KB')
|
||||
if library.compress_type != zipfile.ZIP_STORED:
|
||||
raise ValueError('Native library must be stored uncompressed')
|
||||
raw.seek(library.header_offset + 26)
|
||||
name, extra = struct.unpack('<HH', raw.read(4))
|
||||
if (library.header_offset + 30 + name + extra) % 16384:
|
||||
raise ValueError('APK native library entry is not 16 KB aligned')
|
||||
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
|
||||
raise ValueError('Missing third-party notices')
|
||||
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
|
||||
source = json.loads(archive.read('assets/SourceMetadata.json'))
|
||||
components = catalog['components']
|
||||
ids = [item['id'] for item in components]
|
||||
if catalog['schemaVersion'] != 1 or len(ids) != len(set(ids)) or not {
|
||||
'crossdesk', 'minirtc', 'openfec', 'openh264', 'libiconv', 'android-ndk', 'svt-av1'}.issubset(ids):
|
||||
raise ValueError('Incomplete About catalog')
|
||||
for component in components:
|
||||
if not component['documents']:
|
||||
raise ValueError('Missing component documents')
|
||||
for document in component['documents']:
|
||||
text = archive.read('assets/'+document['asset'])
|
||||
if not text or hashlib.sha256(text).hexdigest() != document['sha256']:
|
||||
raise ValueError('Incomplete license document: '+document['asset'])
|
||||
for document in component.get('buildDocuments', []):
|
||||
if not archive.read('assets/'+document['asset']):
|
||||
raise ValueError('Missing build document')
|
||||
if source['schemaVersion'] != 1 or len(source['revision']) != 40:
|
||||
raise ValueError('Missing source revision')
|
||||
for document in source['buildDocuments']:
|
||||
if not archive.read('assets/'+document['asset']):
|
||||
raise ValueError('Missing source build instructions')
|
||||
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, notices, About catalog and documents')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
verify(sys.argv[1])
|
||||
@@ -0,0 +1,7 @@
|
||||
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories { google(); mavenCentral() }
|
||||
}
|
||||
rootProject.name = 'CrossDeskAndroid'
|
||||
include ':app'
|
||||
Reference in New Issue
Block a user