Add authenticated WiFi transport with BLE fallback

This commit is contained in:
2026-10-01 14:32:18 +08:00
parent 071f06d365
commit f011d8357d
55 changed files with 6327 additions and 29 deletions
@@ -1,5 +1,12 @@
# 03 移动端接入需求
## WiFi 传输第一阶段(协议 1.2)
- DEVICE-MOBILE-WIFI-TRANSPORT:BLE 为会话前提;加密 BLE 交付 WiFi 地址与一次性短期凭据,以实际连接和身份验证判定可用,不比较 SSID 或限制同子网。WebSocket 复用公共业务、单所有者与串行执行。
- DEVICE-MOBILE-WIFI-LIFECYCLE:BLE 独立保活;蓝牙断开撤销全部通道,WiFi 失败回退 BLE。只读可重取,结果不明写入不得重放。旧通道与回调隔离。
- WEB-MOBILE-PRESENCE:顶栏“连接设备:名称 · WiFi/蓝牙”,无会话“连接设备:未连接”;切换不重复通知连接。
- TEST-MOBILE-WIFI-TRANSPORT:覆盖双端向量、安全、在途故障、实际网络恢复、权限与传输对照;模拟与真实证据分别记录。
本文件定义核桃派侧行为,手机产品需求与协议分别见 `../移动端相关内容/开发要求/移动端需求.md`、`../移动端相关内容/开发要求/设备通信协议.md`。当前是已确认的实施契约,不代表已部署。
| 编号 | 要求 |
+1 -1
View File
@@ -1 +1 @@
2026-09-29T22:15+08:00
2026-10-01T10:37+08:00
+5 -1
View File
@@ -869,13 +869,17 @@ def create_app(
mobile = MobileControl(control, network, templates, animations, resolve_content,
apply_resolved_content, effective_default_content, set_default_content,
read_device_status, storage_monitor.get_status, library_order=library_order)
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity)
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity,
wifi_address=lambda: network.get_cached_status().get("active", {}).get("ipv4_address")
if network.get_cached_status().get("active", {}).get("connected") else None)
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
app.state.mobile_control = mobile
app.state.mobile_sessions = mobile_sessions
except Exception:
logger.exception("Mobile control initialization failed; display remains available")
app.state.mobile_runtime = mobile_runtime
from app.mobile.wifi import install_wifi_route
install_wifi_route(app)
@app.get("/api/status")
def get_status() -> dict:
@@ -51,8 +51,8 @@ class MobileControl:
def identity(self):
record = self.identity_record
return dict(device_id=record["device_id"], device_name=record["name"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=1,
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=2,
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"],
limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000))
def settings(self):
@@ -134,7 +134,8 @@ class Handshake:
class Cipher:
def __init__(self, material: bytes, transcript: bytes, *, server: bool):
def __init__(self, material: bytes, transcript: bytes, *, server: bool, label=LABEL):
self.label = label
self.tx_direction = 1 if server else 0
self.rx_direction = 1 - self.tx_direction
self.keys = (AESGCM(material[:32]), AESGCM(material[32:64]))
@@ -148,7 +149,7 @@ class Cipher:
raise ProtocolError("BAD_REQUEST")
seq = struct.pack("!Q", self.tx_sequence)
d = self.tx_direction
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, LABEL + self.transcript + bytes([d]) + seq)
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, self.label + self.transcript + bytes([d]) + seq)
self.tx_sequence += 1
return result
@@ -157,7 +158,7 @@ class Cipher:
if self.failed or not 25 <= len(record) <= MAX_MESSAGE or int.from_bytes(record[:8], "big") != self.rx_sequence:
raise ValueError()
seq, d = record[:8], self.rx_direction
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], LABEL + self.transcript + bytes([d]) + seq)
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], self.label + self.transcript + bytes([d]) + seq)
self.rx_sequence += 1
return result
except Exception:
+11 -2
View File
@@ -23,7 +23,7 @@ def checked_name(value):
class SessionManager:
def __init__(self, dispatch, identity, *, clock=time.monotonic):
def __init__(self, dispatch, identity, *, clock=time.monotonic, wifi_address=lambda: None):
self.dispatch = dispatch
self.identity = identity
self.clock = clock
@@ -37,6 +37,8 @@ class SessionManager:
self.connected_at = self.last_activity = 0
self.generation = 0
self.peer_mtu = 23
from .wifi import WifiSession
self.wifi = WifiSession(self, wifi_address)
def connect(self, owner):
with self.lock:
@@ -55,6 +57,7 @@ class SessionManager:
self.opened = False
self.last_request = -1
self.peer_mtu = 23
self.wifi.close()
self.generation += 1
def expired(self):
@@ -68,7 +71,8 @@ class SessionManager:
def status(self):
with self.lock:
return dict(connected=self.opened, client_name=self.client_name if self.opened else None,
generation=self.generation)
generation=self.generation, active_transport=("wifi" if self.wifi.valid() else "ble") if self.opened else None,
wifi_counters=dict(self.wifi.counters))
def accept(self, owner, fragment):
with self.lock:
@@ -119,6 +123,11 @@ class SessionManager:
result = dict(client_name=self.client_name)
elif method == "session.open":
raise RpcError("CONFLICT", "会话已经建立")
elif method == "transport.offer":
result = self.wifi.offer()
elif method == "transport.close":
self.wifi.close()
result = dict(closed=True)
else:
result = self.dispatch(method, params)
response.update(ok=True, result=result)
+164
View File
@@ -0,0 +1,164 @@
"""BLE-owned, single-use encrypted LAN channel. No independent WiFi session."""
import asyncio
import base64
import hashlib
import ipaddress
import json
import secrets
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from .protocol import Cipher, ProtocolError, json_object, MAX_MESSAGE
LABEL = b'QMS-WIFI-1'
def wifi_cipher(secret, channel_id, *, server):
transcript = hashlib.sha256(channel_id.encode('ascii')).digest()
material = HKDF(algorithm=hashes.SHA256(), length=72, salt=transcript,
info=LABEL + transcript).derive(secret)
return Cipher(material, transcript, server=server, label=LABEL)
def encode(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')
class WifiSession:
# All methods run under the owning SessionManager's reentrant lock.
def __init__(self, sessions, address):
self.sessions, self.address = sessions, address
self.counters = dict(requests=0, rx_bytes=0, tx_bytes=0)
self.close()
def close(self):
self.pending = None
self.channel = None
self.cipher = None
self.last_request = -1
self.last_activity = 0
def valid(self, channel=None):
s = self.sessions
return bool(self.channel and s.opened and not s.expired() and
(channel is None or channel == self.channel) and
s.clock() - self.last_activity < 10)
def offer(self):
from .session import RpcError
if self.valid():
raise RpcError('BUSY', 'WiFi 通道已经启用')
self.close()
try:
host = str(ipaddress.IPv4Address(self.address()))
if ipaddress.ip_address(host).is_unspecified or ipaddress.ip_address(host).is_loopback:
raise ValueError()
except (ValueError, TypeError):
raise RpcError('NOT_READY', '设备 WiFi 尚未取得地址') from None
channel = secrets.token_hex(16)
secret = secrets.token_bytes(32)
self.pending = (channel, secret, self.sessions.clock() + 10)
return dict(channel_id=channel, secret=base64.b64encode(secret).decode('ascii'),
host=host, port=8080, path='/ws/mobile', expires_in_ms=10000,
device_id=self.sessions.identity()['device_id'])
def attach(self, packet):
if not isinstance(packet, bytes) or not 57 <= len(packet) <= MAX_MESSAGE + 32:
raise ProtocolError('BAD_REQUEST')
if not self.sessions.opened or self.sessions.expired() or not self.pending or self.valid():
raise ProtocolError('NOT_READY')
channel, secret, expires = self.pending
if packet[:32] != channel.encode('ascii') or self.sessions.clock() >= expires:
raise ProtocolError('NOT_READY')
cipher = wifi_cipher(secret, channel, server=True)
hello = json_object(cipher.decrypt(packet[32:]))
device_id = self.sessions.identity()['device_id']
if hello != dict(device_id=device_id):
raise ProtocolError('BAD_REQUEST')
self.pending = None
self.channel, self.cipher = channel, cipher
self.last_activity = self.sessions.clock()
return channel, cipher.encrypt(encode(dict(device_id=device_id, ready=True)))
def accept(self, channel, packet):
from .session import RpcError
if not self.valid(channel):
raise ProtocolError('NOT_READY')
request = json_object(self.cipher.decrypt(packet))
rid = request.get('id')
if not isinstance(rid, str) or not 1 <= len(rid) <= 64 or not rid.isascii() or not rid.isdecimal():
raise ProtocolError('BAD_REQUEST')
response = dict(id=rid)
try:
if int(rid) <= self.last_request:
raise RpcError('CONFLICT', '请求编号已使用')
self.last_request = int(rid)
method, params = request.get('method'), request.get('params')
if not isinstance(method, str) or not isinstance(params, dict):
raise RpcError('BAD_REQUEST', '请求格式无效')
if method == 'transport.ping':
result = dict(alive=True)
elif method.startswith(('session.', 'transport.', 'wifi.')) or method == 'task.get':
raise RpcError('BAD_REQUEST', '此操作须使用蓝牙')
else:
result = self.sessions.dispatch(method, params)
response.update(ok=True, result=result)
except RpcError as error:
response.update(ok=False, error=dict(code=error.code, message=error.message, retryable=False))
except Exception:
response.update(ok=False, error=dict(code='DEVICE_ERROR', message='设备操作失败,请刷新状态', retryable=False))
self.last_activity = self.sessions.clock()
record = self.cipher.encrypt(encode(response))
self.counters['requests'] += 1
self.counters['rx_bytes'] += len(packet)
self.counters['tx_bytes'] += len(record)
return record
def install_wifi_route(app):
from fastapi import WebSocket
@app.websocket('/ws/mobile')
async def mobile_wifi(socket: WebSocket):
sessions = getattr(app.state, 'mobile_sessions', None)
if sessions is None:
await socket.close(code=1008)
return
channel = None
receive = None
await socket.accept()
def locked(function, *args):
with sessions.lock:
return function(*args)
try:
packet = await asyncio.wait_for(socket.receive_bytes(), 5)
channel, reply = await asyncio.to_thread(locked, sessions.wifi.attach, packet)
await socket.send_bytes(reply)
while await asyncio.to_thread(locked, sessions.wifi.valid, channel):
if receive is None:
receive = asyncio.create_task(socket.receive_bytes())
done, _ = await asyncio.wait([receive], timeout=0.25)
if not done:
continue
packet = receive.result()
receive = None
reply = await asyncio.to_thread(locked, sessions.wifi.accept, channel, packet)
# A BLE disconnect while executing a command must not send stale data.
if not await asyncio.to_thread(locked, sessions.wifi.valid, channel):
break
await socket.send_bytes(reply)
except Exception:
# Never log records, secrets, peer addresses or normal disconnections.
pass
finally:
if receive is not None:
receive.cancel()
await asyncio.gather(receive, return_exceptions=True)
def release():
if channel is not None and sessions.wifi.channel == channel:
sessions.wifi.close()
await asyncio.to_thread(locked, release)
try:
await socket.close(code=1000)
except Exception:
pass
@@ -41,7 +41,7 @@
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
</div>
<div class="topbar-statuses" aria-label="设备实时状态">
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">手机未连接</span>
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">连接设备:未连接</span>
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
当前画面:正在读取…
</button>
@@ -769,8 +769,8 @@ export async function refreshStatus({ source = "manual" } = {}) {
const mobile = data.mobile;
if (statusEls.mobile) {
statusEls.mobile.textContent = mobile?.connected
? `手机:${mobile.client_name || "已连接"}`
: (mobile?.available === false ? "手机蓝牙暂不可用" : "手机未连接");
? `连接设备:${mobile.client_name || "手机"} · ${mobile.active_transport === "wifi" ? "WiFi" : "蓝牙"}`
: "连接设备:未连接";
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
announce(`${mobile.client_name || "手机"}已连接`);
@@ -0,0 +1,8 @@
{
"secret": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"channel_id": "0123456789abcdef0123456789abcdef",
"request": "7b226465766963655f6964223a227075626c69632d746573742d646576696365227d",
"response": "7b226465766963655f6964223a227075626c69632d746573742d646576696365222c227265616479223a747275657d",
"client_record": "0000000000000000d92ca79a81950869034538790ce815813153b6f67fda4c7c925fd2d3e8cca821df751e3a9a8ea0c9365dba2533f004114c73",
"server_record": "0000000000000000d38868d91e24a0ce8cdd64ec485ab485312aac9f2c2342131544eb3c1a4c3b2827a823d361f12492a3d5f806004ddf6e097b37d0673ddbf203d07ccbb53981"
}
@@ -0,0 +1,20 @@
"""Public deterministic interop fixture, never live session material."""
import json
from pathlib import Path
from app.mobile.wifi import wifi_cipher
def generate():
secret = bytes(range(32))
channel = '0123456789abcdef0123456789abcdef'
request = b'{"device_id":"public-test-device"}'
response = b'{"device_id":"public-test-device","ready":true}'
c, s = wifi_cipher(secret, channel, server=False), wifi_cipher(secret, channel, server=True)
return dict(secret=secret.hex(), channel_id=channel, request=request.hex(), response=response.hex(),
client_record=c.encrypt(request).hex(), server_record=s.encrypt(response).hex())
if __name__ == '__main__':
root = Path(__file__).resolve().parents[2]
raw = json.dumps(generate(), indent=2) + '\n'
for path in [root / '核桃派软件源代码/tests/fixtures/protocol-wifi-v1.json',
root / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-wifi-v1.json']:
path.write_text(raw, encoding='utf-8')
@@ -50,7 +50,8 @@ def test_progressive_preview_and_revision_cleanup(tmp_path):
from PIL import Image
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
assert mobile.identity()['protocol_minor'] == 1
assert mobile.identity()['protocol_minor'] == 2
assert 'wifi_transport' in mobile.identity()['capabilities']
assert 'library_progressive' in mobile.identity()['capabilities']
created = mobile.templates.create('预览清理', {
'version': 1, 'width': 64, 'height': 64,
@@ -0,0 +1,124 @@
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.mobile.wifi import wifi_cipher, encode, install_wifi_route
from app.mobile.protocol import ProtocolError
from test_mobile_session import connection, request
import base64
def opened(now=None):
now = now or [0]
manager, ble, calls = connection(lambda: now[0])
manager.wifi.address = lambda: '192.0.2.10'
request(manager, ble, 1, '1', 'session.open', {'client_name': '手机'})
return manager, ble, calls, now
def offer(manager, ble):
result = request(manager, ble, 2, '2', 'transport.offer')['result']
client = wifi_cipher(base64.b64decode(result['secret']), result['channel_id'], server=False)
hello = result['channel_id'].encode() + client.encrypt(encode({'device_id': 'test'}))
return result, client, hello
def test_upgrade_rpc_fallback_and_ble_lifetime():
m, ble, calls, now = opened()
generation = m.generation
o, client, hello = offer(m, ble)
channel, ack = m.wifi.attach(hello)
assert json.loads(client.decrypt(ack)) == {'device_id': 'test', 'ready': True}
assert m.status()['active_transport'] == 'wifi' and m.generation == generation
for number, method in enumerate(('status.get', 'content.play', 'transport.ping'), 1):
now[0] += 5
packet = client.encrypt(encode(dict(id=str(number), method=method, params={})))
assert json.loads(client.decrypt(m.wifi.accept(channel, packet)))['ok']
assert calls == ['status.get', 'content.play']
now[0] = 20
assert m.expired() # WiFi traffic never refreshes BLE activity.
with pytest.raises(ProtocolError): m.wifi.accept(channel, b'bad')
m.disconnect('a')
assert m.status()['active_transport'] is None
def test_offer_expiry_identity_tamper_and_single_use():
m, ble, _, now = opened()
o, c, hello = offer(m, ble)
with pytest.raises(ProtocolError): m.wifi.attach(hello[:-1] + bytes([hello[-1] ^ 1]))
assert m.wifi.pending is not None
channel, _ = m.wifi.attach(hello)
with pytest.raises(ProtocolError): m.wifi.attach(hello)
assert m.wifi.valid(channel) # Attacker cannot evict the valid owner.
assert request(m, ble, 3, '3', 'transport.close')['ok']
assert m.status()['active_transport'] == 'ble'
with pytest.raises(ProtocolError): m.wifi.attach(hello)
m, ble, _, now = opened()
_, _, hello = offer(m, ble)
now[0] = 10
with pytest.raises(ProtocolError): m.wifi.attach(hello)
def test_no_wifi_address_and_wrong_identity():
m, ble, _, _ = opened()
m.wifi.address = lambda: None
assert not request(m, ble, 2, '2', 'transport.offer')['ok']
m.wifi.address = lambda: '198.51.100.20'
o = request(m, ble, 3, '3', 'transport.offer')['result']
c = wifi_cipher(base64.b64decode(o['secret']), o['channel_id'], server=False)
with pytest.raises(ProtocolError):
m.wifi.attach(o['channel_id'].encode() + c.encrypt(encode({'device_id': 'other'})))
def test_duplicate_write_not_replayed_and_ble_only_commands_rejected():
m, ble, calls, _ = opened()
o, c, hello = offer(m, ble)
channel, ack = m.wifi.attach(hello); c.decrypt(ack)
for rid, method, expected in [('1', 'content.play', True), ('1', 'content.play', False),
('2', 'wifi.set', False), ('3', 'session.ping', False)]:
packet = c.encrypt(encode(dict(id=rid, method=method, params={})))
assert json.loads(c.decrypt(m.wifi.accept(channel, packet)))['ok'] is expected
assert calls == ['content.play']
def test_real_websocket_records_and_disconnect():
m, ble, calls, _ = opened()
app = FastAPI(); app.state.mobile_sessions = m; install_wifi_route(app)
o, c, hello = offer(m, ble)
with TestClient(app) as client:
with client.websocket_connect('/ws/mobile') as ws:
ws.send_bytes(hello)
assert json.loads(c.decrypt(ws.receive_bytes()))['ready']
ws.send_bytes(c.encrypt(encode(dict(id='1', method='status.get', params={}))))
assert json.loads(c.decrypt(ws.receive_bytes()))['ok']
m.disconnect('a')
assert ws.receive()['type'] == 'websocket.close'
assert calls == ['status.get']
def test_wifi_idle_expiry_and_cross_subnet_not_filtered():
m, ble, _, now = opened()
m.wifi.address = lambda: '198.51.100.42'
o, c, hello = offer(m, ble)
assert o['host'] == '198.51.100.42'
m.wifi.attach(hello)
now[0] = 10
assert not m.wifi.valid()
assert m.status()['active_transport'] == 'ble'
def test_runtime_preserves_existing_transport_statistics():
from app.mobile.bluez import BluezRuntime
m, ble, _, _ = opened()
_, _, hello = offer(m, ble)
m.wifi.attach(hello)
status = BluezRuntime(m).status()
assert status['active_transport'] == 'wifi'
assert isinstance(status['transport'], dict)
assert 'rx_bytes' in status['transport']
def test_public_wifi_vector_is_reproducible():
from pathlib import Path
from generate_wifi_vector import generate
assert generate() == json.loads((Path(__file__).parent / 'fixtures/protocol-wifi-v1.json').read_text(encoding='utf-8'))
@@ -1,5 +1,7 @@
# 核桃派点阵屏控制服务测试流程
TEST-MOBILE-WIFI-TRANSPORT 对应 DEVICE-MOBILE-WIFI-TRANSPORT/LIFECYCLE 与 WEB-MOBILE-PRESENCE,步骤见 `移动端相关内容/安卓app/如何安卓测试/WiFi传输测试.md`。合格标准:BLE 为会话前提、实际直连身份验证、独立密钥、无重复写入、WiFi 回退恢复、BLE 断开撤销、真实重启后新会话,以及网页/App 实际链路显示一致;模拟与实机分别记录。
本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。
## 视觉检查的适用范围与启动门禁
@@ -1,5 +1,7 @@
# Android 可重复测试流程
WiFi 加速与回退专项见 [WiFi 传输测试](WiFi传输测试.md),必须显式按影响选测。
**先读 [按影响选测](按影响选测.md)。以下矩阵列出可选覆盖,不是每轮必跑清单;历史版本专项仅在相关影响存在时执行。**
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
@@ -0,0 +1,30 @@
# WiFi 传输按影响验证
适用 MOBILE-WIFI-TRANSPORT/FALLBACK/SAFETY、DEVICE-MOBILE-WIFI-TRANSPORT/LIFECYCLE、WEB-MOBILE-PRESENCE 和 TEST-MOBILE-WIFI-TRANSPORT。先读按影响选测和排障指南。下列场景须显式选择,不加入普通公共回归。
## 本机与模拟器
- Python:在设备源码根执行 `python -m pytest tests/test_mobile_wifi.py tests/test_mobile_session.py tests/test_mobile_protocol.py tests/test_mobile_control.py`。覆盖一次性凭据、身份、篡改、过期、重复写入、BLE 活跃超时、WiFi 超时、WebSocket、兼容统计字段和公开向量。
- Android:隔离构建运行 `:sharedCore:jvmTest`、`:androidApp:lintDebug`、`:androidApp:assembleDebug`、`:androidApp:assembleDebugAndroidTest`、`:uiTest:assembleDebug`。WifiCryptoTest 验证 Python 向量;HybridRpcTest 验证回退不重放写入、只读重取、网络变化、BLE 心跳和恢复协程异常收敛。
- 模拟器 `run_emulator_ui_test.py --scenario connection --scenario wifiTransport`,选 baseline 和 narrow。使用真实 Controller/Compose 与测试专用传输,验证 WiFi/蓝牙提示、权限原因/入口和断线清理;不代表真实局域网权限或无线链路已通过。
- API 37 运行时权限必须在对应系统验证。拒绝和撤销权限时应仍可 BLE 控制,恢复权限可再探测;低版本真机不能替代此项。
## 登记真机与核桃派
先执行凭据脚本、手机登记与 ADB/root 门禁,核对远端工具。使用 `run_ble_test.py --scenario <场景> --adb <ADB路径> --build <本轮隔离工程目录>`。不输出真实身份、SSID、地址、凭据或原始系统网络转储。
| 场景 | 操作及合格条件 |
|---|---|
| wifi-transport | BLE 加密握手、独立 WiFi 握手、相同未使用 App 缓存的缩略图比较,再由 HybridRpc 自动升级。连续帧/图片的 WiFi 计数增加,直接测试期间 BLE 字节不增加;记录驱动实际刷新率、截止丢失与错误。耗时是样本,不宣称固定加速倍数。 |
| wifi-transport-recovery | 手机初始 WiFi 必须已启用。root 关闭 WiFi,回退 BLE 后状态可读;等待平台完成关闭,再启用并等实际 WiFi 网络出现;自动恢复 WiFi。关闭蓝牙则整体断开且不能继续 RPC;最终恢复两种无线开关并核验。 |
| wifi-transport-network | 仅将核桃派当前已保存且正在使用的 WiFi 原样立即应用;保留密码,通过 BLE 查询任务,完成后核对配置并确认 WiFi 通信恢复。不是不同 SSID 或 DHCP 地址变化的证据。 |
| wifi-transport-restart | 测试会话通过 BLE 临时改名为固定测试标记;宿主仅在该标记且 WiFi 已认证时执行一次真实 systemd restart。旧会话必须关闭,重新发现、BLE 握手和 WiFi 升级后服务实例不同、设备身份相同。 |
| wifi-connection-entry | 生产 Activity 的 WiFi 文案、重扫保留、主动断开和重新连接;按实时语义位置 root 点击,不改变布局。 |
双手机互斥在第二台手机可用时专项验证,缺少设备记未验证。跨子网可达、不同 SSID、AP 隔离等真实拓扑须分别准备环境;单元测试与“不比较 SSID”的实现不能冒充真实拓扑验收。
后台生命周期保持原规则:立即停预览,30 秒关闭两通道;前台重新握手。测试前后核对配置、用户内容、服务健康与测试辅助文件清理。服务重启测试不是整机重启或 OTA 验收。普通传输修改不启动摄像头,不切换实屏测试图案。
## 证据
归档记录本轮源摘要、版本、场景、实际环境、结果、样本计数/耗时、恢复核验、失败与重测。日志仅保留固定阶段名、数字指标和测试代号;禁止把完整 status.get 或网络信息写入归档。必要未做项明确列出。
@@ -91,12 +91,15 @@ def main(argv=None):
return output
api = int(adb('shell', 'getprop', 'ro.build.version.sdk', quiet=True).strip())
permissions = ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT') if api >= 31 else ('ACCESS_FINE_LOCATION',)
if api >= 37 and args.scenario.startswith('wifi-transport'):
permissions += ('ACCESS_LOCAL_NETWORK',)
permission_original = {}
if args.scenario == 'navigation':
existing = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
permission_original = {p: 'android.permission.' + p + ': granted=true' in existing for p in permissions}
layout_original = None
layout_density = None
restart_probe = None
try:
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'):
@@ -126,7 +129,11 @@ def main(argv=None):
raise RuntimeError('Navigation must not retain permission to auto-connect')
if args.root_input:
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
if args.scenario == "connection-entry":
if args.scenario.startswith('wifi-transport'):
if 'wifi is enabled' not in adb('shell', 'cmd', 'wifi', 'status', quiet=True).lower().replace('-', ''):
raise RuntimeError('WiFi transport scenario requires initially enabled WiFi')
adb('push', str(Path(__file__).with_name('authorized_root_command.sh')), '/data/local/tmp/qms-authorized-root.sh', quiet=True)
if args.scenario in ("connection-entry", "wifi-connection-entry"):
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
if args.scenario == "layout":
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
@@ -150,6 +157,10 @@ def main(argv=None):
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
board_args = ['-e', 'device_name', board_name] if board_name else []
if library_hash: board_args += ['-e', 'library_order_hash', library_hash]
if args.scenario == 'wifi-transport-restart':
from wifi_restart_probe import RestartProbe
restart_probe = RestartProbe()
restart_probe.start()
output = adb('shell', 'am', 'instrument', '-w', '-r', *board_args,
'-e', 'scenario', args.scenario,
'-e', 'poll_cycles', str(args.poll_cycles),
@@ -157,7 +168,7 @@ def main(argv=None):
'-e', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(),
'-e', 'verify_current_controls', str(args.verify_current_controls).lower(),
'-e', 'toggle_bluetooth', str(args.toggle_bluetooth).lower(),
'-e', 'class', 'org.qimiaoscreen.controller.' + ('LibraryCacheTest' if args.scenario == 'cache-storage' else
'-e', 'class', 'org.qimiaoscreen.controller.' + ('WifiTransportTest' if args.scenario.startswith('wifi-transport') else 'LibraryCacheTest' if args.scenario == 'cache-storage' else
'LibraryBleCacheTest' if args.scenario == 'library-cache' else
{'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test]),
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
@@ -171,6 +182,14 @@ def main(argv=None):
try: action()
except Exception: errors.append(label)
cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller', quiet=True))
if restart_probe is not None:
cleanup('real service restart', restart_probe.finish)
if args.scenario == 'wifi-transport-recovery':
cleanup('wifi restore', lambda: adb('shell', 'su', '-c', 'svc wifi enable', quiet=True))
cleanup('bluetooth restore', lambda: adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True))
time.sleep(3)
if 'wifi is enabled' not in adb('shell', 'cmd', 'wifi', 'status', quiet=True).lower().replace('-', ''):
errors.append('wifi restoration verification')
if layout_original and layout_density is not None:
cleanup('stop', lambda: root('am force-stop org.qimiaoscreen.controller'))
cleanup('density', lambda: root('wm density ' + layout_density))
@@ -5,6 +5,10 @@ from pathlib import Path
# name: (instrumentation family, board required, side effect)
SCENARIOS = {
'wifi-transport': ('ble', True, 'encrypted WiFi upgrade, identical image transfer comparison; no mutations'),
'wifi-transport-network': ('ble', True, 'reapply currently active board WiFi with password retained; verify hybrid routing recovery'),
'wifi-transport-restart': ('ble', True, 'restart board main service only after authenticated WiFi readiness; verify new BLE/WiFi session'),
'wifi-transport-recovery': ('ble', True, 'phone WiFi and Bluetooth disable/restore; verify fallback and whole-session termination'),
'connection': ('ble', True, 'scan, handshake, disconnect and reconnect'),
'data': ('ble', True, 'read status/settings/storage/library/frame/wifi'),
'library-order': ('ble', True, 'read BLE pages and board order digest'),
@@ -22,6 +26,7 @@ SCENARIOS = {
'remember': ('activity', True, 'forget and reconnect test device'),
'bluetooth-toggle': ('activity', True, 'disable and restore phone Bluetooth'),
'connection-entry': ('layout', True, 'connected entry, rescan and reconnect; layout restore'),
'wifi-connection-entry': ('layout', True, 'production WiFi indicator, root rescan/disconnect/reconnect; no layout changes'),
'layout': ('layout', True, 'three layout configurations and frame bounds; layout restore'),
}
COMMON = ('connection', 'data', 'library-order', 'navigation')
@@ -6,7 +6,7 @@ import re
import subprocess
import time
SCENARIOS = ('connection', 'status', 'content', 'settings', 'navigation', 'layout')
SCENARIOS = ('connection', 'wifiTransport', 'status', 'content', 'settings', 'navigation', 'layout')
CONFIGS = {'baseline': (None, '1.0', '0'), 'narrow': (340, '1.0', '0'),
'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')}
PACKAGE = 'org.qimiaoscreen.uitest'
@@ -0,0 +1,41 @@
"""Read-only deployed source and health verification; emits no network or device identity."""
import hashlib
import json
import shlex
from remote_support import ROOT, connect, execute
FILES = ['app/main.py', 'app/mobile/session.py', 'app/mobile/protocol.py', 'app/mobile/control.py',
'app/mobile/wifi.py', 'app/static/index.html', 'app/static/views/device.js', 'FEATURE_UPDATED_AT']
def main():
client, fields = connect()
try:
code, _, _ = execute(client, 'command -v python3 >/dev/null && command -v systemctl >/dev/null')
if code: raise RuntimeError('Remote tools unavailable')
program = '''import hashlib,json,urllib.request
from pathlib import Path
names=NAMES
digests={n:hashlib.sha256((Path('/opt/matrix-screen-controller')/n).read_bytes()).hexdigest() for n in names}
s=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/status',timeout=10))
d=s['screen']['driver_status']
print(json.dumps({'digests':digests,'health':{'mobile_available':s['mobile']['available'],
'active_transport':s['mobile'].get('active_transport'),'driver_available':s['screen']['driver_available'],
'driver_running':d.get('running'),'actual_refresh_rate_hz':d.get('actual_refresh_rate_hz'),
'deadline_misses':d.get('deadline_misses'),'driver_error_absent':not d.get('last_error'),
'feature_updated_at':s['service']['feature_updated_at'],'software_version':s['service']['software_version']}}))
'''.replace('NAMES', repr(FILES))
code, output, _ = execute(client, 'python3 -c ' + shlex.quote(program), password=fields['密码'])
if code: raise RuntimeError('Deployment verification failed')
result = json.loads(output)
expected = {n: hashlib.sha256((ROOT / '核桃派软件源代码' / n).read_bytes()).hexdigest() for n in FILES}
if result['digests'] != expected: raise RuntimeError('Deployed source differs from current source')
code, _, _ = execute(client, 'systemctl is-active --quiet matrix-screen-controller.service')
if code: raise RuntimeError('Main service is not active')
print(json.dumps(dict(source_matches=True, service_active=True, **result['health'])))
finally:
client.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,45 @@
"""Explicit real-system restart test, synchronized by a transient test session name."""
import json
import threading
import time
from remote_support import connect, execute
class RestartProbe:
def __init__(self):
self.stop = threading.Event()
self.error = None
self.completed = False
self.thread = threading.Thread(target=self.run, daemon=True)
def start(self):
self.thread.start()
def run(self):
client = None
try:
client, fields = connect()
code, _, _ = execute(client, 'command -v python3 >/dev/null && command -v systemctl >/dev/null')
if code: raise RuntimeError('Required remote tools unavailable')
deadline = time.monotonic() + 120
while not self.stop.wait(1):
if time.monotonic() >= deadline:
raise RuntimeError('Authenticated restart readiness was not observed')
code, output, _ = execute(client, "python3 -c \"import urllib.request,json; s=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/status',timeout=5)); m=s.get('mobile',{}); print(json.dumps({'ready':m.get('connected') and m.get('active_transport')=='wifi' and m.get('client_name')=='android-test-restart-ready'}))\"")
if code or not json.loads(output).get('ready'):
continue
code, _, _ = execute(client, 'systemctl restart matrix-screen-controller.service && systemctl is-active --quiet matrix-screen-controller.service', password=fields['密码'], timeout=60)
if code: raise RuntimeError('Real service restart failed')
self.completed = True
return
except Exception:
self.error = 'Real service restart coordination failed; no private diagnostics emitted'
finally:
if client is not None: client.close()
def finish(self):
self.stop.set()
self.thread.join(timeout=65)
if self.thread.is_alive() or self.error or not self.completed:
raise RuntimeError(self.error or 'Real service restart did not complete')
@@ -1,5 +1,14 @@
# Android 测试常见问题与排障
## WiFi 传输经验
- 原 `mobile.transport` 是 BLE 计数对象,不能改为字符串。实际业务链路使用新增 `mobile.active_transport`;以 BluezRuntime 组合后的状态测试,避免只测试 SessionManager 漏掉字段覆盖。
- `cmd wifi status` 的启用文字可能为 `Wifi` 或 `Wi-Fi`,只在内存中忽略大小写与连字符后判断,不输出包含 SSID/IP 的原始内容。
- `invokeOnCompletion` 不是协程异常处理器。网络监听的 collectLatest 内恢复屏障也可能因 BLE 已断开而失败;必须在 launch 主体捕获并结束会话,不能仅在完成回调做清理,否则可能断开成功后仍崩溃。
- 网络回调取消不能截断正在发出的加密 BLE 消息。通道协商/撤销须有界地完成 BLE 交换,再响应取消;旧网络回调按注册实例隔离。
- 无线开关恢复分为“启用命令完成”和“网络实际可用”。测试先等关闭稳定,再启用并等待 Network 回调;一次重测通过不能单独证明此前恢复超时的根因。
- 模拟器冷启动超时不代表 Compose 测试失败。先检查已有 Emulator、加速能力和登记系统镜像,不重复下载;可用独立进程记录启动诊断、确认 boot_completed 后运行选定场景。只关闭本次启动实例。
测试前阅读本文件及 [可重复测试流程](README.md)。这里维护可复用的方法;单次失败、重测次数、耗时与验收结果写入 `../测试结果归档/`。本页依据 [四栏版真机记录](../测试结果归档/2026-09-26_四栏界面优化.md) 整理,方法有效不等于所有同类错误均有相同根因。
## 1. root 命令在 instrumentation 中失败
@@ -1,5 +1,7 @@
# Android 按变更影响选测(当前规则)
WiFi 通道新增场景、前置条件和验收标准见 [WiFi 传输测试](WiFi传输测试.md)。`wifi-transport-recovery` 切手机无线开关,`wifi-transport-network` 重新应用设备当前网络,`wifi-transport-restart` 重启主服务,均不得并入普通全量或公共回归。`wifiTransport` 是独立模拟器场景,不接实机。
适用 MOBILE-TEST-SELECTION、TEST-MOBILE-SELECTION、TEST-MOBILE-INTEGRATION。本文优先于历史版本专项验收中的固定执行顺序。授权决定怎样操作设备,不代表每轮必须使用设备。
## 选择范围
@@ -1,5 +1,11 @@
# Android 首版详细设计
## WiFi 加速扩展
App 0.3.0 / code 6。DeviceRpc 保留 BLE 分片与握手;WifiRpc 使用完整加密 WebSocket 记录;HybridRpc 串行业务路由并独立 BLE 保活,BLE 断开销毁两通道。MobilePlatform 可选 WifiPlatform 隔离 Android 网络 API;旧设备无 wifi_transport 能力则保持 BLE。AndroidWifi 监听非 VPN 的 WiFi Network,将 OkHttp SocketFactory 绑定该网络,不改变进程或系统默认网络,不要求互联网验证。
协议 1.2、一次性凭据及密钥布局见通信协议。平台网络事件撤销旧 Socket;按 2/5/10/30 秒重试;心跳与切网任务始终 BLE。后台立即停止预览,30 秒关闭全部连接。结果不明写入不重放,回读并提示。API 37 局域网权限拒绝保留 BLE,连接页提供设置入口。`usesCleartextTraffic` 允许本地 ws 握手,但业务和通道证明均应用层 AES-GCM 加密,不发送明文身份/业务/密钥。
本设计对应 MOBILE-* 和协议 1.0。已实现项以本文件说明及源码为准,真实通过范围见测试结果归档;测试目标不自动等于已完成验收。
## 1. 模块与依赖方向
@@ -13,8 +13,8 @@ android {
applicationId = "org.qimiaoscreen.controller"
minSdk = 26
targetSdk = 37
versionCode = 5
versionName = "0.2.2"
versionCode = 6
versionName = "0.3.0"
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -26,6 +26,7 @@ android {
}
}
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation(project(":sharedCore"))
implementation(project(":sharedUi"))
implementation(libs.activity.compose)
@@ -108,6 +108,10 @@ class LayoutTest {
try {
stage("initial-connect")
if (visible(By.res("connected_scan_disconnect"), 35_000, scroll = true) == null) connectScanned()
if (InstrumentationRegistry.getArguments().getString("scenario") == "wifi-connection-entry") {
checkNotNull(visible(By.textContains("连接状态:WiFi"), 25_000, scroll = true)) { "WiFi transport indicator unavailable" }
stage("WiFi connection indicator verified")
}
val summary = tagged("connection_summary")
assertTrue(summary.text.startsWith("当前连接设备:"))
assertTrue(summary.text.contains(" · "))
@@ -0,0 +1,169 @@
package org.qimiaoscreen.controller
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.UiDevice
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.*
import org.junit.Test
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.qimiaoscreen.core.*
import kotlin.system.measureTimeMillis
/** Real registered BLE + WiFi path; no screen content writes or camera use. */
@RunWith(AndroidJUnit4::class)
class WifiTransportTest {
@Test fun wifiUpgradeAndRecovery() = runBlocking {
val i = InstrumentationRegistry.getInstrumentation()
val args = InstrumentationRegistry.getArguments()
val scenario = args.getString("scenario")!!
val context = i.targetContext
val ui = UiDevice.getInstance(i)
fun stage(s: String) = i.sendStatus(2, Bundle().apply { putString("stream", "WiFi transport: $s\n") })
fun root(command: String) = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-root.sh $command").trim()
val ble = AndroidBle(context)
val expected = args.getString("device_name")!!
val found = CompletableDeferred<DiscoveredDevice>()
var router: HybridRpc? = null
var rpc: DeviceRpc? = null
var lan: WifiRpc? = null
val wifi = AndroidWifi(context)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val transport = kotlinx.coroutines.flow.MutableStateFlow("ble")
val lost = CompletableDeferred<Unit>()
var wifiChanged = false
var bluetoothChanged = false
try {
ble.scan({ if (it.name == expected) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) })
val device = withTimeout(20_000) { found.await() }; ble.stopScan()
val link = ble.connect(device.handle)
rpc = DeviceRpc(link, JavaSecureChannel())
val identity = rpc.open("android-test-a")
assertTrue(identity.getValue("capabilities").jsonArray.any { it.jsonPrimitive.content == "wifi_transport" })
stage("BLE ready")
wifi.start()
withTimeout(15_000) { wifi.state.first { it.available } }
// Same uncached image request on both transports, with byte equality.
val item = rpc.call("library.list").getValue("items").jsonArray.first().jsonObject
val params = buildJsonObject { for (key in listOf("type", "id", "revision")) put(key, item.getValue(key)) }
var bleImage = ""
val bleMs = measureTimeMillis { bleImage = rpc.call("library.thumbnail", params).getValue("data_base64").jsonPrimitive.content }
val offer = rpc.call("transport.offer")
lan = WifiRpc(wifi.connect(offer), wifi.crypto(offer)); lan.open(offer)
var wifiImage = ""
val wifiMs = measureTimeMillis { wifiImage = lan.call("library.thumbnail", params).getValue("data_base64").jsonPrimitive.content }
assertEquals(bleImage, wifiImage)
val status = lan.call("status.get")
assertEquals("wifi", status.getValue("mobile").jsonObject.getValue("active_transport").jsonPrimitive.content)
stage("image_equal=true base64_bytes=${wifiImage.length} ble_ms=$bleMs wifi_ms=$wifiMs")
val beforeCounters = status.getValue("mobile").jsonObject
val driverBefore = status.getValue("screen").jsonObject.getValue("driver_status").jsonObject
repeat(3) {
assertEquals(wifiImage, lan.call("library.thumbnail", params).getValue("data_base64").jsonPrimitive.content)
lan.call("frame.get")
}
val afterRead = lan.call("status.get")
val afterCounters = afterRead.getValue("mobile").jsonObject
val bleDelta = afterCounters.getValue("transport").jsonObject.getValue("rx_bytes").jsonPrimitive.long -
beforeCounters.getValue("transport").jsonObject.getValue("rx_bytes").jsonPrimitive.long
val wifiDelta = afterCounters.getValue("wifi_counters").jsonObject.getValue("requests").jsonPrimitive.long -
beforeCounters.getValue("wifi_counters").jsonObject.getValue("requests").jsonPrimitive.long
assertEquals(0L, bleDelta)
assertTrue(wifiDelta >= 6)
val driverAfter = afterRead.getValue("screen").jsonObject.getValue("driver_status").jsonObject
assertTrue(driverAfter["running"]?.jsonPrimitive?.boolean == true)
assertTrue(driverAfter["last_error"] == JsonNull || driverAfter["last_error"]?.jsonPrimitive?.contentOrNull.isNullOrEmpty())
stage("WiFi image/frame reads=$wifiDelta BLE_rx_delta=$bleDelta driver_hz=${driverAfter["actual_refresh_rate_hz"]} deadline_before=${driverBefore["deadline_misses"]} deadline_after=${driverAfter["deadline_misses"]}")
lan.close(); lan = null; rpc.call("transport.close"); wifi.stop()
router = HybridRpc(rpc, link, wifi, scope, identity.getValue("device_id").jsonPrimitive.content,
{ mode, reason -> transport.value = mode; stage("route=$mode reason=${reason ?: "ready"}") },
{ stage("BLE session lost"); lost.complete(Unit) })
withTimeout(20_000) { transport.first { it == "wifi" } }
stage("automatic upgrade verified")
assertTrue(router.call("status.get").containsKey("state"))
if (scenario == "wifi-transport-restart") {
val oldInstance = router.call("status.get").getValue("service").jsonObject.getValue("instance_id")
router.call("session.rename", buildJsonObject { put("client_name", "android-test-restart-ready") })
stage("ready for coordinated real service restart")
withTimeout(75_000) { lost.await() }
stage("service restart closed both transports")
delay(4_000)
val rediscovered = CompletableDeferred<DiscoveredDevice>()
ble.scan({ if (it.name == expected) rediscovered.complete(it) }, { rediscovered.completeExceptionally(IllegalStateException(it)) })
val next = withTimeout(25_000) { rediscovered.await() }; ble.stopScan()
val newLink = ble.connect(next.handle)
rpc = DeviceRpc(newLink, JavaSecureChannel())
val fresh = rpc.open("android-test-a")
assertEquals(identity.getValue("device_id"), fresh.getValue("device_id"))
transport.value = "ble"
router = HybridRpc(rpc, newLink, wifi, scope, fresh.getValue("device_id").jsonPrimitive.content,
{ mode, _ -> transport.value = mode }, { error("New session unexpectedly lost") })
withTimeout(25_000) { transport.first { it == "wifi" } }
assertNotEquals(oldInstance, router.call("status.get").getValue("service").jsonObject.getValue("instance_id"))
stage("new BLE handshake and WiFi upgrade verified after real service restart")
}
if (scenario == "wifi-transport-network") {
val before = router.call("wifi.get")
val saved = before.getValue("saved").jsonObject
val active = before.getValue("active").jsonObject
check(active["connected"]?.jsonPrimitive?.boolean == true && active["ssid"] == saved["ssid"])
val security = saved.getValue("security").jsonPrimitive.content
check(security == "open" || saved["password_configured"]?.jsonPrimitive?.boolean == true)
val change = buildJsonObject {
for (key in listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers")) saved[key]?.let { put(key, it) }
put("expected_revision", before.getValue("revision"))
put("password_action", if (security == "open") "none" else "keep"); put("activation", "immediate")
}
val task = router.call("wifi.set", change).getValue("task_id")
withTimeout(45_000) {
while (true) {
val state = router.call("task.get", buildJsonObject { put("task_id", task) }).getValue("state").jsonPrimitive.content
check(state != "failed")
if (state == "succeeded") break
delay(1_000)
}
}
assertFalse(lost.isCompleted)
val after = router.call("wifi.get")
assertEquals(saved, after.getValue("saved"))
assertEquals(active["ssid"], after.getValue("active").jsonObject["ssid"])
withTimeout(45_000) {
while (true) {
val current = router.call("status.get").getValue("mobile").jsonObject
if (current["active_transport"]?.jsonPrimitive?.content == "wifi") break
delay(1_000)
}
}
stage("board current-network reactivation and WiFi recovery verified; settings retained")
}
if (scenario == "wifi-transport-recovery") {
wifiChanged = true
root("svc wifi disable")
withTimeout(15_000) { transport.first { it == "ble" } }
assertFalse(lost.isCompleted)
assertTrue(router.call("status.get").containsKey("state"))
stage("WiFi off -> BLE fallback verified")
delay(3_000) // Let the platform complete radio shutdown before enabling again.
root("svc wifi enable")
withTimeout(30_000) { wifi.state.first { it.available } }
stage("phone WiFi network available after restore")
withTimeout(45_000) { transport.first { it == "wifi" } }
stage("WiFi restored -> automatic upgrade verified")
bluetoothChanged = true
root("cmd bluetooth_manager disable")
withTimeout(12_000) { lost.await() }
assertTrue(runCatching { router.call("status.get") }.isFailure)
stage("BLE off -> whole session closed verified")
root("cmd bluetooth_manager enable")
}
stage("passed")
} finally {
router?.close(); lan?.close(); rpc?.close(); wifi.stop(); ble.stopScan(); scope.cancel()
if (wifiChanged) root("svc wifi enable")
if (bluetoothChanged) root("cmd bluetooth_manager enable")
}
}
}
@@ -1,11 +1,14 @@
<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" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application android:label="奇妙小屏幕" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true">
<application android:label="奇妙小屏幕" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true" android:usesCleartextTraffic="true">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -64,6 +64,7 @@ class AndroidBle(private val context: Context) {
@SuppressLint("MissingPermission")
private class GattLink : DeviceLink {
override val disconnected = CompletableDeferred<Unit>()
@Volatile override var mtu = 23
private set
val ready = CompletableDeferred<Unit>()
@@ -167,6 +168,7 @@ private class GattLink : DeviceLink {
} catch (_: TimeoutCancellationException) { null } finally { signalPending = null }
}
override fun close() {
disconnected.complete(Unit)
if (closed) return
closed = true
handler.removeCallbacks(discoveryFallback)
@@ -0,0 +1,123 @@
package org.qimiaoscreen.controller
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.*
import android.os.Build
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.json.*
import okhttp3.*
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.qimiaoscreen.core.*
import org.qimiaoscreen.core.Protocol
import java.net.Proxy
import java.util.Base64
import java.util.concurrent.TimeUnit
class AndroidWifi(private val context: Context) : WifiPlatform {
private val manager = context.getSystemService(ConnectivityManager::class.java)
private val mutable = MutableStateFlow(WifiNetworkState())
override val state: StateFlow<WifiNetworkState> = mutable
private var callback: ConnectivityManager.NetworkCallback? = null
private var network: Network? = null
private var link: LanLink? = null
private var addresses = ""
fun permitted() = Build.VERSION.SDK_INT < 37 || context.checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") == PackageManager.PERMISSION_GRANTED
@Synchronized fun refresh() {
val available = permitted() && network != null
if (!available) link?.close()
mutable.value = WifiNetworkState(mutable.value.generation + 1, available,
if (!permitted()) "未允许局域网访问,仍可使用蓝牙" else if (network == null) "手机未连接 WiFi,正在使用蓝牙" else null)
}
@Synchronized override fun start() {
if (callback != null) return
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(n: Network, caps: NetworkCapabilities) {
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && !caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
synchronized(this@AndroidWifi) {
if (callback !== this) return
if (network != n) { link?.close(); network = n; addresses = ""; refresh() }
}
}
}
override fun onLinkPropertiesChanged(n: Network, properties: LinkProperties) {
synchronized(this@AndroidWifi) {
if (callback !== this) return
if (network == n) {
val current = properties.linkAddresses.toString() + properties.routes.toString()
if (current != addresses) { link?.close(); addresses = current; refresh() }
}
}
}
override fun onLost(n: Network) {
synchronized(this@AndroidWifi) { if (callback === this && network == n) { network = null; refresh() } }
}
}
callback = cb
try {
manager.registerNetworkCallback(NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(), cb)
refresh()
} catch (_: Exception) { callback = null; mutable.value = WifiNetworkState(reason = "无法监听 WiFi,正在使用蓝牙") }
}
@Synchronized override fun stop() {
callback?.let { runCatching { manager.unregisterNetworkCallback(it) } }; callback = null
link?.close(); link = null; network = null; addresses = ""
mutable.value = WifiNetworkState(mutable.value.generation + 1)
}
override fun crypto(offer: JsonObject) = JavaSecureChannel.wifi(
Base64.getDecoder().decode(offer.getValue("secret").jsonPrimitive.content), offer.getValue("channel_id").jsonPrimitive.content)
override suspend fun connect(offer: JsonObject): DeviceLink {
check(permitted())
val selected = synchronized(this) { checkNotNull(network) }
val host = offer.getValue("host").jsonPrimitive.content
require(host.split('.').let { it.size == 4 && it.all { part -> part.toIntOrNull() in 0..255 } })
require(offer.getValue("port").jsonPrimitive.int == 8080 && offer.getValue("path").jsonPrimitive.content == "/ws/mobile")
val client = OkHttpClient.Builder().socketFactory(selected.socketFactory).proxy(Proxy.NO_PROXY)
.connectTimeout(5, TimeUnit.SECONDS).readTimeout(0, TimeUnit.SECONDS)
.retryOnConnectionFailure(false).build()
val candidate = LanLink(client)
try {
synchronized(this) { check(network == selected); link?.close(); link = candidate }
candidate.socket = client.newWebSocket(Request.Builder().url("ws://$host:8080/ws/mobile").build(), candidate.listener)
withTimeout(5_000) { candidate.ready.await() }
return candidate
} catch (e: Exception) { candidate.close(); throw e }
}
}
private class LanLink(private val client: OkHttpClient) : DeviceLink {
override val mtu = 65536
val ready = CompletableDeferred<Unit>()
override val disconnected = CompletableDeferred<Unit>()
private val incoming = Channel<ByteArray>(4)
@Volatile var socket: WebSocket? = null
@Volatile private var closed = false
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { if (closed) webSocket.cancel() else ready.complete(Unit) }
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
if (bytes.size > Protocol.MAX_MESSAGE || !incoming.trySend(bytes.toByteArray()).isSuccess) close()
}
override fun onMessage(webSocket: WebSocket, text: String) { close() }
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { close() }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { close() }
}
override suspend fun send(fragment: ByteArray) {
check(!closed && fragment.size <= Protocol.MAX_MESSAGE + 32)
check(socket?.send(fragment.toByteString()) == true)
}
override suspend fun receive() = incoming.receive()
@Synchronized override fun close() {
if (closed) return
closed = true
ready.completeExceptionally(DeviceTransportFailure("WiFi 连接不可用"))
incoming.close(DeviceTransportFailure("WiFi 连接已中断"))
disconnected.complete(Unit); socket?.cancel()
client.connectionPool.evictAll(); client.dispatcher.executorService.shutdown()
}
}
@@ -23,6 +23,15 @@ import org.qimiaoscreen.ui.AppInfo
class MainActivity : ComponentActivity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var controller: Controller
private lateinit var wifi: AndroidWifi
private val requestLanPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { wifi.refresh() }
private fun requestLan(manual: Boolean = true) {
if (Build.VERSION.SDK_INT >= 37 && !wifi.permitted()) {
if (manual && !shouldShowRequestPermissionRationale("android.permission.ACCESS_LOCAL_NETWORK")) {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
} else requestLanPermission.launch("android.permission.ACCESS_LOCAL_NETWORK")
} else wifi.refresh()
}
private val permissions get() = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
private fun permitted() = permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
private val requestPermission: ActivityResultLauncher<Array<String>> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
@@ -42,8 +51,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val ble = AndroidBle(applicationContext)
wifi = AndroidWifi(applicationContext)
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
val platform = object : MobilePlatform {
override fun wifi(): WifiPlatform = wifi
override fun requestWifiPermission() { requestLan() }
override val model = Build.MODEL
override val libraryCache: LibraryCache = AndroidLibraryCache(applicationContext)
override fun load(key: String) = preferences.getString(key, null)
@@ -54,13 +66,17 @@ class MainActivity : ComponentActivity() {
override fun crypto(): SecureChannel = JavaSecureChannel()
}
controller = Controller(platform, scope)
if (Build.VERSION.SDK_INT >= 37 && !wifi.permitted() && !preferences.getBoolean("lan_permission_requested", false)) {
preferences.edit().putBoolean("lan_permission_requested", true).apply()
requestLan(manual = false)
}
setContent { QimiaoApp(controller, onScan = {
if (permitted()) controller.scan() else requestPermission.launch(permissions)
}, appInfo = AppInfo(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toString(), BuildConfig.BUILD_DATE), decode = { encoded ->
runCatching { val bytes = Base64.decode(encoded, Base64.DEFAULT); BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() }.getOrNull()
}) }
}
override fun onStart() { super.onStart(); if (::controller.isInitialized && permitted()) controller.foreground(true) }
override fun onStart() { super.onStart(); if (::wifi.isInitialized) wifi.refresh(); if (::controller.isInitialized && permitted()) controller.foreground(true) }
override fun onStop() { if (::controller.isInitialized) controller.foreground(false); super.onStop() }
override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() }
}
@@ -8,6 +8,8 @@ import kotlinx.serialization.json.*
import kotlin.time.Clock
interface MobilePlatform {
fun wifi(): WifiPlatform? = null
fun requestWifiPermission() {}
val model: String
val libraryCache: LibraryCache? get() = null
fun load(key: String): String?
@@ -22,6 +24,7 @@ data class RememberedDevice(val id: String, val name: String, val shortId: Strin
data class WifiDraft(val ssid: String = "", val security: String = "wpa-psk", val manual: Boolean = false,
val address: String = "", val prefix: String = "24", val gateway: String = "", val dns: String = "")
data class ControllerState(
val transport: String = "ble", val transportReason: String? = null,
val remembered: List<RememberedDevice> = emptyList(), val signal: Int? = null,
val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null,
val wifiDraft: WifiDraft? = null,
@@ -71,7 +74,8 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
private var requestedDeviceId: String? = null
private var activeLink: DeviceLink? = null
fun wifiDraft(value: WifiDraft) = change { it.copy(wifiDraft = value) }
private var rpc: DeviceRpc? = null
private var rpc: HybridRpc? = null
fun requestWifiPermission() = platform.requestWifiPermission()
private val commands = Mutex()
private var scanJob: Job? = null
private var pollJob: Job? = null
@@ -139,7 +143,10 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
val id = identity.getValue("device_id").jsonPrimitive.content
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
rpc = pending
val supportsWifi = identity["capabilities"]?.jsonArray?.any { it.jsonPrimitive.content == "wifi_transport" } == true
rpc = HybridRpc(pending, checkNotNull(activeLink), if (supportsWifi) platform.wifi() else null, scope, id,
changed = { transport, reason -> if (generation == connectionGeneration) change { it.copy(transport = transport, transportReason = reason) } },
lost = { message -> if (generation == connectionGeneration) { connectionLost(message); pollJob?.cancel() } })
platform.save("last_device_id", id)
platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString())
persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id,
@@ -153,6 +160,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
} catch (e: Exception) {
pending?.close()
if (generation == connectionGeneration) {
rpc?.close()
rpc = null; activeLink = null
if (e !is CancellationException) change { it.copy(identity = null, connectedDevice = null, phase = "未连接", error = e.message ?: "连接失败") }
}
@@ -167,11 +175,13 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
}
private fun connectionLost(message: String) {
connectionGeneration++
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; resetLibraryQueue()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
}
fun foreground(value: Boolean) {
foreground = value
rpc?.foreground(value)
releaseJob?.cancel()
if (value) {
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
@@ -203,7 +213,14 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
val generation = connectionGeneration
commandJob = scope.launch {
try { commands.withLock { block() } }
catch (e: DeviceFailure) { change { it.copy(error = e.message) } }
catch (e: DeviceFailure) {
change { it.copy(error = e.message) }
if (e.code == "RESULT_UNKNOWN") {
try { refreshStatus() }
catch (_: DeviceFailure) { /* Preserve the uncertain-write message. */ }
catch (failure: Exception) { if (failure !is CancellationException) connectionLost("连接已中断,请重新连接") }
}
}
catch (e: Exception) {
if (e !is CancellationException && generation == connectionGeneration) {
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
@@ -10,6 +10,7 @@ data class DiscoveredDevice(val handle: String, val name: String, val signal: In
/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */
interface DeviceLink {
val disconnected: kotlinx.coroutines.Deferred<Unit>? get() = null
val mtu: Int
suspend fun send(fragment: ByteArray)
suspend fun receive(): ByteArray
@@ -0,0 +1,140 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.*
data class WifiNetworkState(val generation: Long = 0, val available: Boolean = false, val reason: String? = null)
interface WifiPlatform {
val state: StateFlow<WifiNetworkState>
fun start()
fun stop()
suspend fun connect(offer: JsonObject): DeviceLink
fun crypto(offer: JsonObject): SecureChannel
}
/** Complete encrypted records, never BLE-sized fragments. */
class WifiRpc(private val link: DeviceLink, private val crypto: SecureChannel) {
private val mutex = Mutex()
private var request = 0L
suspend fun open(offer: JsonObject) = withTimeout(5_000) {
val deviceId = offer.getValue("device_id").jsonPrimitive.content
val hello = buildJsonObject { put("device_id", deviceId) }.toString().encodeToByteArray()
link.send(offer.getValue("channel_id").jsonPrimitive.content.encodeToByteArray() + crypto.encrypt(hello))
val response = Protocol.json.parseToJsonElement(crypto.decrypt(link.receive()).decodeToString(throwOnInvalidSequence = true)).jsonObject
require(response["device_id"]?.jsonPrimitive?.content == deviceId && response["ready"]?.jsonPrimitive?.boolean == true)
}
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = mutex.withLock {
withTimeout(5_000) {
val id = (++request).toString()
val raw = buildJsonObject { put("id", id); put("method", method); put("params", params) }
link.send(crypto.encrypt(raw.toString().encodeToByteArray()))
val response = Protocol.json.parseToJsonElement(crypto.decrypt(link.receive()).decodeToString(throwOnInvalidSequence = true)).jsonObject
require(response["id"]?.jsonPrimitive?.content == id)
if (response.getValue("ok").jsonPrimitive.boolean) response.getValue("result").jsonObject
else {
val error = response.getValue("error").jsonObject
throw DeviceFailure(error.getValue("code").jsonPrimitive.content, error.getValue("message").jsonPrimitive.content)
}
}
}
fun close() = link.close()
}
/** BLE owns lifetime; routing changes only at business-request boundaries. */
class HybridRpc(
private val ble: DeviceRpc, private val bleLink: DeviceLink, private val wifi: WifiPlatform?,
parent: CoroutineScope, private val deviceId: String,
private val changed: (String, String?) -> Unit, private val lost: (String) -> Unit,
) {
private val scope = CoroutineScope(parent.coroutineContext + SupervisorJob(parent.coroutineContext[Job]))
private val gate = Mutex()
private var lan: WifiRpc? = null
private var closed = false
private var enabled = true
private val readOnly = setOf("status.get", "storage.get", "settings.get", "library.list", "library.summary",
"library.preview", "library.thumbnail", "content.default.get", "frame.get")
init {
scope.launch {
try { while (isActive) { delay(2_000); ble.call("session.ping") } }
catch (e: Exception) { if (e !is CancellationException) fail() }
}
bleLink.disconnected?.let { signal -> scope.launch { signal.await(); fail() } }
if (wifi != null) {
wifi.start()
scope.launch {
try {
wifi.state.collectLatest { network ->
lan?.close() // Immediately interrupt in-flight IO on old networks.
gate.withLock { fallback(network.reason) }
if (!network.available) return@collectLatest
var attempt = 0
while (currentCoroutineContext().isActive) {
if (!enabled) { delay(1_000); continue }
try {
gate.withLock {
if (closed) return@collectLatest
// Network callback cancellation must not truncate a BLE record.
val offer = withContext(NonCancellable) { ble.call("transport.offer") }
currentCoroutineContext().ensureActive()
require(offer.getValue("device_id").jsonPrimitive.content == deviceId)
var candidate: WifiRpc? = null
try {
withTimeout(5_000) {
candidate = WifiRpc(wifi.connect(offer), wifi.crypto(offer))
candidate!!.open(offer)
}
currentCoroutineContext().ensureActive()
lan = candidate
changed("wifi", null)
} catch (e: Exception) { candidate?.close(); throw e }
}
attempt = 0
while (currentCoroutineContext().isActive) {
delay(5_000)
gate.withLock { checkNotNull(lan).call("transport.ping") }
}
} catch (e: CancellationException) {
if (e !is TimeoutCancellationException) throw e
} catch (_: Exception) { /* Recover through the BLE execution barrier. */ }
gate.withLock { fallback("WiFi 暂不可用,正在使用蓝牙") }
delay(listOf(2_000L, 5_000L, 10_000L, 30_000L)[(attempt++).coerceAtMost(3)])
}
}
} catch (e: Exception) {
if (e !is CancellationException) fail()
}
}
}
}
private fun fail() { if (!closed) { close(); lost("蓝牙连接已中断,请重新连接") } }
private suspend fun fallback(reason: String?) {
lan?.close(); lan = null
if (!closed && wifi != null) withContext(NonCancellable) { ble.call("transport.close") }
if (!closed) changed("ble", reason)
}
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = gate.withLock {
check(!closed)
val channel = lan
if (channel == null || method.startsWith("session.") || method.startsWith("transport.") ||
method.startsWith("wifi.") || method == "task.get") return@withLock ble.call(method, params)
try { channel.call(method, params) }
catch (e: DeviceFailure) { throw e }
catch (e: Exception) {
if (e is CancellationException && e !is TimeoutCancellationException) throw e
fallback("WiFi 已中断,正在使用蓝牙")
if (method in readOnly) ble.call(method, params)
else {
ble.call("status.get") // Resolve the old execution before subsequent writes.
throw DeviceFailure("RESULT_UNKNOWN", "操作结果未确认,已恢复蓝牙,请检查设备状态后再操作")
}
}
}
fun foreground(value: Boolean) { enabled = value }
fun close() {
if (closed) return
closed = true; scope.cancel(); lan?.close(); lan = null; wifi?.stop(); ble.close()
}
}
@@ -27,12 +27,30 @@ class JavaSecureChannel(
}.generateKeyPair(),
private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) },
) : SecureChannel {
private val label = "QMS-BLE-1".encodeToByteArray()
private var label = "QMS-BLE-1".encodeToByteArray()
private var material: ByteArray? = null
private var transcript = ByteArray(0)
private var tx = 0L
private var rx = 0L
private var failed = false
companion object {
fun wifi(secret: ByteArray, channelId: String): JavaSecureChannel {
require(secret.size == 32 && channelId.matches(Regex("[0-9a-f]{32}")))
return JavaSecureChannel().apply {
label = "QMS-WIFI-1".encodeToByteArray()
transcript = hash(channelId.encodeToByteArray())
val prk = mac(transcript, secret)
var block = ByteArray(0)
var expanded = ByteArray(0)
for (i in 1..3) {
block = mac(prk, block + label + transcript + byteArrayOf(i.toByte()))
expanded += block
}
material = expanded.copyOf(72)
prk.fill(0); expanded.fill(0)
}
}
}
private fun fixed(value: BigInteger): ByteArray = value.toByteArray().let {
if (it.size >= 32) it.copyOfRange(it.size - 32, it.size) else ByteArray(32 - it.size) + it
}
@@ -0,0 +1,136 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.*
import kotlin.test.*
private class PlainCrypto : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
private class RpcFixture : WifiPlatform {
override val state = MutableStateFlow(WifiNetworkState(1, true))
var wifiWrites = 0
var bleWrites = 0
var wifiReads = 0
var failRead = false
var failWrite = false
var wrongIdentity = false
var blePings = 0
var failBarrier = false
var stopped = false
val offer = buildJsonObject { put("channel_id", "0".repeat(32)); put("device_id", "test") }
val ble = object : DeviceLink {
override val mtu = 247
override val disconnected = CompletableDeferred<Unit>()
val reassembler = Reassembler()
val replies = Channel<ByteArray>(Channel.UNLIMITED)
var sequence = 0L
override suspend fun send(fragment: ByteArray) {
val (kind, payload) = reassembler.accept(fragment) ?: return
val raw = if (kind == 1) byteArrayOf(2) else {
val r = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
val method = r.getValue("method").jsonPrimitive.content
if (method == "transport.close" && failBarrier) throw DeviceTransportFailure("BLE lost during fallback")
if (method == "content.play") bleWrites++
if (method == "session.ping") blePings++
val result = if (method == "transport.offer") offer else buildJsonObject { put("ble", true) }
buildJsonObject { put("id", r.getValue("id")); put("ok", true); put("result", result) }.toString().encodeToByteArray()
}
Protocol.encode(if (kind == 1) 2 else 3, sequence++, raw, mtu).forEach { replies.send(it) }
}
override suspend fun receive() = replies.receive()
override fun close() { disconnected.complete(Unit); replies.close() }
}
override fun start() {}
override fun stop() { stopped = true }
override fun crypto(offer: JsonObject): SecureChannel = PlainCrypto()
override suspend fun connect(offer: JsonObject): DeviceLink = object : DeviceLink {
override val mtu = 65536
val replies = Channel<ByteArray>(Channel.UNLIMITED)
var opened = false
override suspend fun send(fragment: ByteArray) {
if (!opened) {
opened = true
replies.send(buildJsonObject { put("device_id", if (wrongIdentity) "wrong" else "test"); put("ready", true) }.toString().encodeToByteArray())
return
}
val r = Protocol.json.parseToJsonElement(fragment.decodeToString()).jsonObject
val method = r.getValue("method").jsonPrimitive.content
if (method == "content.play") { wifiWrites++; if (failWrite) throw DeviceTransportFailure("lost reply") }
if (method == "status.get") { wifiReads++; if (failRead) throw DeviceTransportFailure("lost read") }
replies.send(buildJsonObject { put("id", r.getValue("id")); put("ok", true); put("result", buildJsonObject { put("wifi", true) }) }.toString().encodeToByteArray())
}
override suspend fun receive() = replies.receive()
override fun close() { replies.close() }
}
}
class HybridRpcTest {
private suspend fun awaitTrue(predicate: () -> Boolean) = withTimeout(3_000) { while (!predicate()) delay(10) }
@Test fun bleFailureDuringNetworkFallbackIsHandled() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var mode = "ble"; var lost = false; var uncaught = false
val parent = CoroutineScope(coroutineContext + CoroutineExceptionHandler { _, _ -> uncaught = true })
val router = HybridRpc(ble, f.ble, f, parent, "test", { t, _ -> mode = t }, { lost = true })
try {
awaitTrue { mode == "wifi" }
f.failBarrier = true
f.state.value = WifiNetworkState(2, false)
awaitTrue { lost }
delay(50)
assertFalse(uncaught)
} finally { router.close() }
}
@Test fun upgradesReadFailureFallsBackAndWriteIsNeverReplayed() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { fail(it) })
try {
awaitTrue { transport == "wifi" }
assertTrue(router.call("status.get").containsKey("wifi"))
f.failWrite = true
val error = assertFailsWith<DeviceFailure> { router.call("content.play") }
assertEquals("RESULT_UNKNOWN", error.code)
assertEquals(1, f.wifiWrites); assertEquals(0, f.bleWrites)
assertEquals("ble", transport)
assertTrue(router.call("status.get").containsKey("ble"))
} finally { router.close() }
assertTrue(f.stopped)
}
@Test fun readRetriesOnBleAndNetworkChangeDoesNotCloseBle() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"; var lost = false
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { lost = true })
try {
awaitTrue { transport == "wifi" }
f.failRead = true
assertTrue(router.call("status.get").containsKey("ble"))
assertEquals(1, f.wifiReads)
f.state.value = WifiNetworkState(2, false)
delay(100)
assertFalse(lost)
f.failRead = false; f.state.value = WifiNetworkState(3, true)
awaitTrue { transport == "wifi" }
delay(2_100)
assertTrue(f.blePings > 0)
f.ble.close()
awaitTrue { lost }
assertTrue(f.stopped)
assertFails { router.call("status.get") }
Unit
} finally { router.close() }
}
@Test fun identityMismatchNeverUpgrades() = runBlocking {
val f = RpcFixture(); f.wrongIdentity = true
val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { fail(it) })
try { delay(200); assertEquals("ble", transport); assertTrue(router.call("status.get").containsKey("ble")) }
finally { router.close() }
}
}
@@ -0,0 +1,23 @@
package org.qimiaoscreen.core
import kotlinx.serialization.json.*
import kotlin.test.*
class WifiCryptoTest {
private val vector = Protocol.json.parseToJsonElement(checkNotNull(javaClass.getResourceAsStream("/protocol-wifi-v1.json"))
.bufferedReader().use { it.readText() }).jsonObject
private fun bytes(key: String) = vector.getValue(key).jsonPrimitive.content.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun cipher() = JavaSecureChannel.wifi(bytes("secret"), vector.getValue("channel_id").jsonPrimitive.content)
@Test fun pythonGoldenRecordsAndReplay() {
val c = cipher()
assertContentEquals(bytes("client_record"), c.encrypt(bytes("request")))
assertContentEquals(bytes("response"), c.decrypt(bytes("server_record")))
assertFails { c.decrypt(bytes("server_record")) }
}
@Test fun tamperAndWrongChannel() {
val record = bytes("server_record"); record[record.lastIndex] = (record.last().toInt() xor 1).toByte()
assertFails { cipher().decrypt(record) }
val c = JavaSecureChannel.wifi(bytes("secret"), "f".repeat(32))
assertFails { c.decrypt(bytes("server_record")) }
}
}
@@ -0,0 +1,8 @@
{
"secret": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"channel_id": "0123456789abcdef0123456789abcdef",
"request": "7b226465766963655f6964223a227075626c69632d746573742d646576696365227d",
"response": "7b226465766963655f6964223a227075626c69632d746573742d646576696365222c227265616479223a747275657d",
"client_record": "0000000000000000d92ca79a81950869034538790ce815813153b6f67fda4c7c925fd2d3e8cca821df751e3a9a8ea0c9365dba2533f004114c73",
"server_record": "0000000000000000d38868d91e24a0ce8cdd64ec485ab485312aac9f2c2342131544eb3c1a4c3b2827a823d361f12492a3d5f806004ddf6e097b37d0673ddbf203d07ccbb53981"
}
@@ -93,20 +93,29 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
}
}
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Unit) {
var selected by remember { mutableStateOf<RememberedDevice?>(null) }
var renaming by remember { mutableStateOf(false) }
var name by remember { mutableStateOf("") }
LazyColumn(Modifier.fillMaxWidth().testTag("connection_list"), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (state.identity != null && state.transportReason != null) item {
Text(state.transportReason.orEmpty(), style = MaterialTheme.typography.bodySmall)
if (state.transportReason.orEmpty().contains("局域网")) TextButton(c::requestWifiPermission) { Text("允许局域网访问") }
}
item { Text("已保存的设备", style = MaterialTheme.typography.titleMedium) }
if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") }
items(state.remembered, key = { it.id }) { device ->
Card(Modifier.fillMaxWidth().combinedClickable(onClick = { c.connectRemembered(device) }, onLongClick = { selected = device; renaming = false })) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Text(device.name); Text("代号 ${device.shortId}", style = MaterialTheme.typography.bodySmall) }
Text(if (state.identity?.text("device_id") == device.id) "已连接" else "点击连接")
if (state.identity?.text("device_id") == device.id) {
FlowRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("连接状态:${if (state.transport == "wifi") "WiFi" else "蓝牙"}")
Text("已连接")
}
} else Text("点击连接")
}
}
}
@@ -120,7 +129,7 @@ private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Un
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(if (connected) state.identity!!.text("device_name") else device.name)
Text(if (connected) "已连接 · 蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}"
Text(if (connected) "连接状态:${if (state.transport == "wifi") "WiFi" else "蓝牙"} · 已连接 · 蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}"
else "扫描信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall)
}
Button({ if (connected) c.disconnect() else c.connect(device, c.rememberedFor(device)?.id) },
@@ -22,6 +22,9 @@ class FixtureActivity : ComponentActivity() {
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
}
class FixturePlatform : MobilePlatform {
var wifiEnabled = false
val fixtureWifi = FixtureWifi()
override fun wifi(): WifiPlatform? = if (wifiEnabled) fixtureWifi else null
override val model = "模拟测试手机"
private val saved = mutableMapOf<String, String>()
val calls = mutableListOf<String>()
@@ -56,7 +59,9 @@ private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
val method = request.getValue("method").jsonPrimitive.content
platform.calls += method
val result = when (method) {
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"]}"""
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"${if (platform.wifiEnabled) ",\"wifi_transport\"" else ""}]}"""
"transport.offer" -> """{"device_id":"00000000-0000-4000-8000-000000000001","channel_id":"00000000000000000000000000000000"}"""
"transport.close" -> """{"closed":true}"""
"session.ping" -> """{"alive":true}"""
"status.get" -> """{"state":{"animation_playback":{"active":false}}}"""
"frame.get" -> """{"frame_revision":"fixture","data_base64":"fixture-image"}"""
@@ -77,3 +82,33 @@ private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
override suspend fun receive() = messages.receive()
override fun close() { messages.close() }
}
class FixtureWifi : WifiPlatform {
override val state = kotlinx.coroutines.flow.MutableStateFlow(WifiNetworkState(1, true))
override fun start() {}
override fun stop() {}
override fun crypto(offer: JsonObject) = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
override suspend fun connect(offer: JsonObject): DeviceLink = object : DeviceLink {
override val mtu = 65536
val replies = Channel<ByteArray>(Channel.UNLIMITED)
var opened = false
override suspend fun send(fragment: ByteArray) {
if (!opened) {
opened = true
replies.send("""{"device_id":"00000000-0000-4000-8000-000000000001","ready":true}""".encodeToByteArray())
} else {
val request = Protocol.json.parseToJsonElement(fragment.decodeToString()).jsonObject
replies.send(buildJsonObject { put("id", request.getValue("id")); put("ok", true)
put("result", buildJsonObject { put("state", buildJsonObject {}); put("alive", true) })
}.toString().encodeToByteArray())
}
}
override suspend fun receive() = replies.receive()
override fun close() { replies.close() }
}
}
@@ -7,6 +7,19 @@ import org.junit.Test
import org.junit.Assert.*
class IsolatedUiTest {
@Test fun wifiTransport() {
ui.runOnIdle { ui.activity.platform.wifiEnabled = true }
connect()
ui.waitUntil(5_000) { ui.activity.controller.state.value.transport == "wifi" }
ui.onNodeWithText("连接状态:WiFi", substring = false).assertExists()
ui.onNodeWithText("连接状态:WiFi · 已连接", substring = true).assertExists()
ui.runOnIdle { ui.activity.platform.fixtureWifi.state.value = org.qimiaoscreen.core.WifiNetworkState(2, false, "未允许局域网访问,仍可使用蓝牙") }
ui.waitUntil(5_000) { ui.activity.controller.state.value.transport == "ble" }
ui.onNodeWithText("允许局域网访问").performScrollTo().assertExists()
ui.onNodeWithText("连接状态:蓝牙", substring = false).performScrollTo().assertExists()
ui.runOnIdle { ui.activity.controller.disconnect() }
ui.onNodeWithText("连接状态:蓝牙", substring = false).assertDoesNotExist()
}
@get:Rule val ui = createAndroidComposeRule<FixtureActivity>()
private fun click(text: String) { ui.onNodeWithText(text).performClick() }
private fun connect() {
@@ -24,6 +37,7 @@ class IsolatedUiTest {
ui.onNodeWithTag("controller_error").assertDoesNotExist()
ui.runOnIdle { ui.activity.platform.scanError = false }
connect()
ui.onNodeWithText("连接状态:蓝牙", substring = false).assertExists()
ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick()
ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null }
}
@@ -1,5 +1,7 @@
# Android 环境、构建与交付
App 0.3.0 / versionCode 6 增加 BLE 会话内 WiFi 加速,协议 1.2;仅 debug。Android 使用固定 OkHttp 4.12.0,Gradle verification-metadata.xml 记录依赖 SHA-256。设备复用 FastAPI/uvicorn/cryptography,无新增板端依赖。保持设备 VERSION 和发布记录,不导出 OTA/IMG。
## 安装门槛
Android Studio 是正常安装软件,由用户运行已核验官方安装包。安装包放用户普通下载目录,不放仓库或 Codex bin。准备好后停止等待用户安装,不自动运行安装器。安装时建议采用默认位置,首次向导安装 Android SDK;不必创建项目,也不必先下载模拟器或 NDK。
@@ -48,7 +48,7 @@ def main():
build_date = re.search(r'BUILD_DATE = "([^"]+)"', build_config)[1]
manifest = dict(app_version=version, version_code=code, signing='debug', build_date=build_date,
status='development-validation-see-test-report', apk_sha256=digest(apk),
certificate_sha256=certificate, protocol_major=1, protocol_minor=1, minimum_android_api=26,
certificate_sha256=certificate, protocol_major=1, protocol_minor=2, minimum_android_api=26,
target_android_api=37, build_tools='36.0.0', toolchain=versions, sources=entries)
(destination / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
(destination / 'SHA256SUMS').write_text(f'{digest(apk)} {name}\n', encoding='utf-8')
@@ -0,0 +1,7 @@
# 奇妙小屏幕 0.3.0 debug(versionCode 6)
仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。
本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。
SHA-256:`0aa43b8d6cb64648aad4fb5bff6a9b5e3eb3b71b707a17bc67708d197b54e36d`。源码摘要与签名证书摘要见 manifest.json。
@@ -0,0 +1 @@
0aa43b8d6cb64648aad4fb5bff6a9b5e3eb3b71b707a17bc67708d197b54e36d 奇妙小屏幕-0.3.0-debug.apk
@@ -0,0 +1,203 @@
{
"app_version": "0.3.0",
"version_code": 6,
"signing": "debug",
"build_date": "2026-10-01 10:58 +08:00",
"status": "development-validation-see-test-report",
"apk_sha256": "0aa43b8d6cb64648aad4fb5bff6a9b5e3eb3b71b707a17bc67708d197b54e36d",
"certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d",
"protocol_major": 1,
"protocol_minor": 2,
"minimum_android_api": 26,
"target_android_api": 37,
"build_tools": "36.0.0",
"toolchain": {
"kotlin": "2.4.20",
"agp": "9.3.1",
"compose": "1.12.1",
"coroutines": "1.10.2",
"serialization": "1.9.0"
},
"sources": [
{
"path": "androidApp/build.gradle.kts",
"sha256": "5c456db18998f21af503350e626366ce951ebe6aea7d81a9595fd44a6fbe7ec0"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt",
"sha256": "7b75279ea8bba61fd9a35848339f765e931abb2748dcb2ab08f37f85afd21084"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt",
"sha256": "f286cf70cd00b7e872371f0fbc55f6be46e52602ef6890e974aca1c32eb006fa"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt",
"sha256": "22a26ed18216edf21273b2fdd285865ce00c7d1e456eca61cb3e23064989bb42"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryBleCacheTest.kt",
"sha256": "01636b443b36532e8b6aa55eaf9ef0976697122362ca24f05a1da6bb958e419f"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt",
"sha256": "a225a1d2a59bee7f693acf82feefc285e4777a63c5c8a711b61523429e547360"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt",
"sha256": "5ce335ff84fee01230733971964fefde3fcc6103172d3c40fee64f50838b7bcf"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiTransportTest.kt",
"sha256": "541d8b2368a7b8e835d4bc2fc07c974fe932c43d72ef488b50ad22dda3a4b03c"
},
{
"path": "androidApp/src/main/AndroidManifest.xml",
"sha256": "f46f93fb739fa984ee2ad247b0d40678c41b71454cd30be0b04ea780668f1a06"
},
{
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt",
"sha256": "343fb5e0aff77a0bea81895eff9863f10907663210f42368f02c1ae840161340"
},
{
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt",
"sha256": "c8904caa25123b0085678d8e01e6bbf27ab0eb25e3767334a539c1dc7aebdba1"
},
{
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidWifi.kt",
"sha256": "d94d52c524c1f23b50c283fde1157787f27f4b5e92362d5daea72ce4c435d839"
},
{
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt",
"sha256": "890ae503c6cc02b001f2d96a8c6240b10ad7e988b8e4d3374af85c83762422eb"
},
{
"path": "build.gradle.kts",
"sha256": "feb3b6e64b846f0408cc40e95ae4b632ce9288a56bd6151bc3bbcc46f0549b49"
},
{
"path": "gradle/libs.versions.toml",
"sha256": "ed393bb2b4d81a162f6b3d21968eeca113e23c2f5d5d22c3be9d104bc592a633"
},
{
"path": "gradle/verification-metadata.xml",
"sha256": "e44824772ec2c9913a7ac2ce3608478a6276524184b6ff9c0526d55f67cc91af"
},
{
"path": "gradle/wrapper/gradle-wrapper.jar",
"sha256": "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7"
},
{
"path": "gradle/wrapper/gradle-wrapper.properties",
"sha256": "2cb0f9ddf992a26fc4947c91019136bff5e1ccfbf93bd905402b1abf69d15bc5"
},
{
"path": "gradle.properties",
"sha256": "e7d684b7795fd04ab2e7e96038fa2bf4f9fd633578aa3bb2f5fe367b4f5ec159"
},
{
"path": "gradlew",
"sha256": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a"
},
{
"path": "gradlew.bat",
"sha256": "475c4f08cd57cf2faa819e7f36d72aa93f0ad646ea23a8f7fa3ef54dee1cbc52"
},
{
"path": "README.md",
"sha256": "7232ebbac0899152e312429fecaf6b3e14b31265b0c50a95c99a6213aa04b2cd"
},
{
"path": "settings.gradle.kts",
"sha256": "3a1adf811d65cfd2ce28bfeb711990511a29094b8a5b5378f2860c86c9fe83e2"
},
{
"path": "sharedCore/build.gradle.kts",
"sha256": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt",
"sha256": "8c9fd985504340efc1fa82c2ad15f937bda51473d97f4fe5e63b8552ca0fa3e5"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt",
"sha256": "35673e7e28d44c5a3d784a205d3f8f86ddcf534751b31b3e27e0ea93ec756747"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt",
"sha256": "d260abe091fa35ad79a663cdae9284147a000e51d8ee1f87f6d7e28138de3fe6"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt",
"sha256": "99264aff225076ef1b423801b9bba8797e0e06a9e759339bc52e896620e4b694"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt",
"sha256": "bde40388bb74c9d47a4e099a14a0baeca3b6bda1539023dbd7dca831c6f24039"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/WifiTransport.kt",
"sha256": "3e0f4fe8ad47edb43ddd0acd720a0970afe902f5de937ce087a86eafc20143b2"
},
{
"path": "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt",
"sha256": "98b05b59c42f714f0ff356a4ed942e1564ecf3c02ded9ed9a108fc7b1cba00e5"
},
{
"path": "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt",
"sha256": "5d26d3a50c5887d72dc91b2081273fbe4a5a968f01451ef75ab78903d5d42217"
},
{
"path": "sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt",
"sha256": "f5dcc82c9dc7a4ab6851ade222eb333239b298637cdbf0dd08531838310bd0c9"
},
{
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt",
"sha256": "2ddb06c4476952a69bf01c0a3a7164625ae8c0bfe9194349450a2e254888d4b3"
},
{
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/HybridRpcTest.kt",
"sha256": "11956411e6323a60f1835dcf113958095d74e06947bdecb802076234dcab2435"
},
{
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt",
"sha256": "fca298fb2266ee7e68e6df76d837f7f191e0046aa5ba321a304c4b4369e064a6"
},
{
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/WifiCryptoTest.kt",
"sha256": "ef051432f40fa9a5072efde6e590683a25f6eb2ca2ddb2fa1806089a8bfeeca0"
},
{
"path": "sharedCore/src/jvmTest/resources/protocol-v1.json",
"sha256": "6280953b95a3f5d1ae531d7feaa6a62848ffa3e438e58308d2a27f04723803dc"
},
{
"path": "sharedCore/src/jvmTest/resources/protocol-wifi-v1.json",
"sha256": "387fd59e65930001d190f4900b118958d41ee664c119ece9cd2ed15eed53d665"
},
{
"path": "sharedUi/build.gradle.kts",
"sha256": "d469b330d248647e6e93f98d69eb46730adab329ebaf71cbfd896d5426fd0ff1"
},
{
"path": "sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt",
"sha256": "46b8e61309fc60a194779695063bd4bf57dae9962481333dceb2062378d1acfc"
},
{
"path": "uiTest/build.gradle.kts",
"sha256": "958d9d5509c9b0fd3076f0d22a4544b66e11a4ba47e728107b8ea357cdaffaf3"
},
{
"path": "uiTest/src/main/AndroidManifest.xml",
"sha256": "ef50ab1e26f0caaadd7997799dd47d510cb7b4042b421ebe5354f4d2d42708dc"
},
{
"path": "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt",
"sha256": "13d2cd43929d16216fc1e27d6965752e2699d1fa57f952093afe20530c155bde"
},
{
"path": "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt",
"sha256": "1172df746c4ccc56923ba30c8de56eb0c042be8a7e1c6881d82ae6240d1914bd"
}
]
}
@@ -0,0 +1,54 @@
# WiFi 传输第一阶段验收
App **0.3.0 / versionCode 6**,debug 签名;协议 **1.2**。设备 VERSION 维持 **1.1.3**,功能时间 **2026-10-01T10:37+08:00**。未导出 OTA/IMG,未改显示驱动或用户内容。
交付:`../../构建与发布/交付包/0.3.0-6/`。手机安装 APK 与交付包 SHA-256 一致:`0aa43b8d6cb64648aad4fb5bff6a9b5e3eb3b71b707a17bc67708d197b54e36d`。设备修改文件摘要与源码一致,主服务、BLE 和驱动可用;最终打开生产 App 后设备报告 active_transport=wifi。
## 变更与选测
改动共享 RPC、BLE 会话、Android 网络监听/权限、连接栏和网页状态,故选择协议、状态机、定向真实 BLE/WiFi、真实主服务重启和连接栏测试。普通设置/图库页面、驱动输出和实屏视觉未改变,不跑无关全量回归,不使用摄像头。
| 层级/场景 | 结果与边界 |
|---|---|
| Python 设备侧 | 45 项通过;公开向量、会话、业务、真实 ASGI WebSocket、过期/重放/身份/互斥门禁、兼容统计字段;隔离源码副本再次 45 项通过。 |
| sharedCore JVM | 23 项通过;含 WiFi 跨语言向量 2 项和混合路由 4 项。写入结果不明不重放、只读回退、BLE 心跳和恢复屏障异常有测试。 |
| 构建/lint | debug 产品/测试 APK 和 uiTest 构建成功;lint 0 errors、20 warnings。依赖校验元数据已保存。 |
| 宿主选测/网页 | 选测运行器 9 项通过;device UI Node 测试及修改 JavaScript 语法检查通过。网页文案依赖真实 active_transport,昵称仍以 textContent 渲染。未声称浏览器视觉验收。 |
| 模拟器 connection | API 35,baseline/narrow 均通过。 |
| 模拟器 wifiTransport | API 35,baseline/narrow 均通过;WiFi 提示、蓝牙回退、权限原因/入口和断开清理。使用假平台,不代表系统权限实测。 |
| wifi-transport-recovery | 登记 Android 13 真机通过,20.443 秒:自动升级、手机 WiFi 关闭后 BLE 可读、WiFi 恢复再升级、蓝牙关闭整体断开且未崩溃;无线开关恢复并核验。 |
| wifi-transport-network | 真机通过,20.223 秒:核桃派原网络原配置重新应用、保留密码、BLE 查询任务、WiFi 恢复与配置回读一致。不等于换 SSID 或 DHCP 换地址。 |
| wifi-transport-restart | 真机与真实 systemd 通过,24.256 秒:活动 WiFi 会话中重启主服务,旧会话断开,新 BLE 握手、相同设备身份、不同服务实例、WiFi 再升级。不是 OTA 验收。 |
| wifi-connection-entry | 生产 Activity root 语义输入通过,52.854 秒;实际 WiFi 标识、重扫保留、断开和手动再连,未修改密度/方向。 |
| 部署核验 | 设备源摘要匹配、服务 active、驱动 running 且无最后错误。手机安装摘要匹配交付包,WiFi/蓝牙均恢复。 |
## 传输证据
同一 64×64 PNG 缩略图(Base64 300 字符),未使用 App 图片缓存:恢复专项 BLE 89 ms / WiFi 12 ms;网络专项 91 / 14 ms;重启专项 134 / 21 ms。这些是小样本单次请求,不能外推固定加速倍数或大图库性能。
重启专项额外连续读取图片和当前帧:WiFi 请求计数增加 7,BLE 接收字节增量为 0,证明业务走 WiFi。该短窗口驱动约 97.416 Hz,截止丢失 1→1,driver running、last_error 空。较早健康核验约 97.332 Hz、累计截止丢失 1;收尾再次核验约 97.331 Hz、累计截止丢失 16,驱动仍运行且无最后错误。累计计数随运行增加,未做长期负载因果对照,不能据此宣称长期无退化。
## 失败、修复与重测
1. Kotlin 跨模块可空属性不能 smart cast,使用明确空值处理;OkHttp 的 Protocol 与项目 Protocol 同名,改用显式 import。构建重测通过。
2. 旧测试固定协议次版本 1,随兼容扩展改为 2 并校验能力。新增 runtime 组合状态测试:原 transport 是统计对象,新增 active_transport,避免字段覆盖和旧客户端破坏。
3. WiFi 启用状态文本 Wifi/Wi-Fi 大小写差异导致运行器误拒绝;在内存归一化,不输出原始网络资料。门禁重测通过。
4. 首次实机关闭 BLE 后恢复协程异常逃逸导致测试进程退出。launch 主体捕获恢复屏障异常并补 JVM 回归;完整实机恢复重测通过。
5. 续跑时一次 WiFi 恢复等待 45 秒超时。加入平台关闭稳定等待与“网络实际可用”阶段诊断后通过;不能只因通过就断言此前唯一根因。
6. API 35 模拟器首次冷启动 180 秒超时,未运行 UI。检查 WHPX 可用,复用已有镜像,以独立 headless 实例记录诊断并确认 boot_completed,再跑选择场景,4 项均通过。只关闭本轮模拟器。
7. 隔离设备副本首次遗漏 VERSION,测试收集失败;补齐部署元数据后 45 项通过。该失败是副本准备不足,不是源码依赖原工作区。
## 未验证与不适用
- API 37 系统局域网权限允许/拒绝/撤销尚无对应系统实测;API 35 模拟和 Android 13 真机不能替代。
- 第二台实体手机互斥、真实不同 SSID/跨子网/AP 隔离、IPv4 地址变化未具备本轮环境;对应协议门禁及不依赖 SSID 的实现不等于真实拓扑通过。
- 混合通道的生产 App 前后台补测未执行:自动审批拒绝 ADB root Home/重新打开操作,仅返回 blocked by policy。保持现有 30 秒释放逻辑,不能据历史纯 BLE 测试声明本轮混合通道已通过。
- IPv6、热点、WiFi Direct、独立 WiFi 会话、云连接不属于本阶段。实屏视觉不适用,未启动摄像头或切换测试图案。
经验已更新 `../../如何安卓测试/常见问题与排障.md`;可重复步骤见 `../../如何安卓测试/WiFi传输测试.md`。本报告只记录本轮事实,不替代稳定测试流程。
收尾已删除本轮两个远端临时部署目录,保留可回滚程序备份。脱敏测试结果见 `test-evidence.json`,最终只读健康状态见 `final-health.json`。
## 用户重新授权后的 ADB 复核
用户明确再次授权后,USB 登记、ADB 身份及 root(uid 0)检查通过;生产 App 进程存在,root 启动 MainActivity 成功。随后包含 root Home、后台 35 秒检查和重新打开 App 的命令被执行工具自动审批再次拒绝,仅返回 `blocked by policy`,命令未启动。未更换调用方式绕过拒绝,混合通道前后台补测仍未验证。该拒绝不能解释为手机 USB、ADB 或 root 不可用。
@@ -0,0 +1,13 @@
{
"source_matches": true,
"service_active": true,
"mobile_available": true,
"active_transport": "wifi",
"driver_available": true,
"driver_running": true,
"actual_refresh_rate_hz": 97.331,
"deadline_misses": 16,
"driver_error_absent": true,
"feature_updated_at": "2026-10-01T10:37+08:00",
"software_version": "1.1.3"
}
@@ -0,0 +1,73 @@
{
"ui-connection-2.json": {
"tests": [
{
"scenario": "connection",
"configuration": "baseline",
"result": "passed"
},
{
"scenario": "connection",
"configuration": "narrow",
"result": "passed"
}
],
"failure": null,
"cleanup_errors": [],
"not_selected": [
"status",
"content",
"settings",
"navigation",
"layout"
]
},
"ui-wifi.json": {
"tests": [
{
"scenario": "wifiTransport",
"configuration": "baseline",
"result": "passed"
},
{
"scenario": "wifiTransport",
"configuration": "narrow",
"result": "passed"
}
],
"failure": null,
"cleanup_errors": [],
"not_selected": [
"connection",
"status",
"content",
"settings",
"navigation",
"layout"
]
},
"recovery-2.log": {
"passed": true,
"timing": [
"Time: 20.443"
]
},
"network.log": {
"passed": true,
"timing": [
"Time: 20.223"
]
},
"restart.log": {
"passed": true,
"timing": [
"Time: 24.256"
]
},
"phone-ui.log": {
"passed": true,
"timing": [
"Time: 52.854"
]
}
}
@@ -1,5 +1,9 @@
# 当前实施状态
更新时间:2026-10-01。当前 App **0.3.0 / versionCode 6** 已部署至登记手机,核桃派支持协议 **1.2** 的 BLE 会话内 WiFi 加速。自动升级、WiFi 回退/恢复、BLE 断开、真实主服务重启重新握手及连接栏实机验证通过。APK、摘要与范围见 [WiFi 传输验收](2026-10-01_WiFi传输/README.md) 和 `../构建与发布/交付包/0.3.0-6/`。API 37 系统权限、第二手机、真实跨子网和受自动审批阻止的混合通道后台补测仍未验证。设备 VERSION 维持 1.1.3;未导出 OTA/IMG。
以下为历史状态,不将历史通过自动算作本轮通过。
更新时间:2026-09-29。当前 App 为 **0.2.2 / versionCode 5**,设备协议 1.1 的“显示内容”两档图片与跨重连缓存已部署。交付包见 `../构建与发布/交付包/0.2.2-5/`,本轮选测、实测请求量、失败修复与未验证边界见 [内容缓存验收](2026-09-29_内容缓存/README.md)。登记手机上的最终 APK 与交付包摘要一致;核桃派服务正常,设备 `VERSION` 仍为 1.1.3,功能时间为 `2026-09-29T22:15+08:00`。本轮只做 debug 签名,没有导出 OTA/IMG。
以下为历史验收记录,不将历史通过项自动归为本轮重测:
@@ -1,5 +1,12 @@
# 移动端需求
## WiFi 传输第一阶段
- MOBILE-WIFI-TRANSPORT:两端已有 WiFi 且 BLE 会话有效时自动探测,实际可达即启用,不比较 SSID 或限制同子网。IPv4 首版不包含热点、WiFi Direct、独立 WiFi 或云连接。
- MOBILE-WIFI-FALLBACK:WiFi 使用期间 BLE 每 2 秒保活;WiFi 失败回退,2/5/10/30 秒退避再试;蓝牙断开整体断开。网络事件撤销旧通道;静默故障目标 10 秒内回退。
- MOBILE-CONNECTION-TRANSPORT:连接页当前设备“已连接”左侧显示“连接状态:蓝牙/WiFi”,列表一致、窄屏换行。权限拒绝仍可蓝牙控制,提供局域网授权入口。
- MOBILE-WIFI-SAFETY:身份绑定当前 BLE 会话;不重放结果不明的写操作,回读并提示。后台立即停预览,30 秒释放全部连接。
## 1. 产品与范围
名称:奇妙小屏幕。Android 首版为 BLE 控制器,支持 Android 8.0(API 26)以上。连接不要求核桃派已联网,也不要求手机与设备在同一 WiFi。设备、内容、设置为三个一级页面,中文为首版界面语言。
@@ -1,5 +1,15 @@
# 奇妙小屏幕 BLE 控制协议 1.0
## WiFi 通道扩展 1.2
能力 wifi_transport。加密 BLE transport.offer 返回 channel_id(16 随机字节小写 hex)、secret(32 随机字节 Base64)、device_id、host(当前 WiFi IPv4)、port=8080、path=/ws/mobile、expires_in_ms=10000。每次 offer 撤销前一候选;活动通道须先 close。BLE transport.close 在串行门禁内撤销候选与活动通道,作为回退执行屏障。session.*、wifi.*、task.get 与通道协商只走 BLE。
KDF:T=SHA256(ASCII(channel_id));HKDF-SHA256(secret,salt=T,info=ASCII("QMS-WIFI-1")||T,length=72)。密钥及 nonce prefix 布局沿用 BLE Cipher,AAD 标签为 QMS-WIFI-1;双向序号独立从 0 开始,不共享 BLE 计数。最大加密记录 65536 字节。
WebSocket 首个二进制消息为 ASCII(channel_id) 的 32 字节加加密 JSON {device_id},5 秒内送达;设备验证凭据、BLE 会话及身份后一次消费,返回加密 {device_id,ready:true}。之后每条二进制消息为加密 RPC JSON,格式沿用原协议;WiFi 请求编号独立递增。transport.ping 是 WiFi 唯一通道级方法,每 5 秒调用、5 秒超时,不延长 BLE 活跃时间。设备 10 秒无有效 WiFi 消息撤销通道。
会话锁串行业务和撤销;BLE 断开使 WiFi 失效。只读失败回退后可重取;写入结果不明不重放,回读并提示。旧凭据、错误身份、篡改、重放、非二进制、超长消息拒绝;凭据不进 URL/日志/磁盘。未认证连接不得踢掉合法通道。WiFi 建立不增加 mobile.generation;mobile.active_transport 为 ble/wifi/null。
本文是首版实现契约。Python/Kotlin 加密黄金向量、分片和 Android 13 真实 BLE 已有通过记录;各业务验收范围见测试结果归档,不能据此推断所有场景通过。命令只能进入设备公共业务服务,不调用网页 DOM,不提供 shell、任意路径或原生驱动地址。变化登记到跨端兼容记录。
## 1. GATT 与广播
@@ -91,3 +101,5 @@ BLE 只接收白名单方法。旧客户端遇新能力忽略;旧设备缺某
## 2026-09-26 兼容扩展
library.list 的用户条目遵循设备持久混合顺序,顺序变化影响 library_revision 和游标。演示条目固定在用户条目之前。
task.get 可附加脱敏 error_code(AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN);旧客户端可忽略。没有可靠原因时用 UNKNOWN。wifi.get 仍不含密码。协议主版本保持 1。
兼容说明:原 mobile.transport 为 BLE 统计对象,保持原义;实际链路新增 active_transport,不改变旧字段类型。
@@ -1,5 +1,9 @@
# 跨端兼容与变更记录
## WiFi 通信第一阶段 / App 0.3.0 (6)
协议 1.2 增量能力 wifi_transport;原 BLE 握手和命令不变。新增 BLE transport.offer/close、加密 WebSocket /ws/mobile 及 mobile.active_transport(ble/wifi/null)。旧 App 忽略新字段,新 App 对旧设备保持 BLE。App 新增网络与 API 37 局域网权限。设备无需新 Python/Debian 依赖,不改驱动;手工可回滚部署,不导出 OTA/IMG、不推进设备发布版本。
## 规则
- 设备版本取设备自己的 VERSION,App 版本取 Android 工程独立版本,BLE 协议主次版本独立编号。
@@ -66,3 +70,5 @@ MOBILE-STATUS/DISCOVERY/PREVIEW:顶栏自动循环、扫描条目断开与
## 2026-09-29 内容图库缓存
DEVICE-MOBILE-LIBRARY-CACHE/MOBILE-LIBRARY-CACHE:设备协议 1.1 在主版本 1 下新增 `library_progressive`、`library.summary` 和 16×16 `library.preview`,保留既有 64×64 `library.thumbnail` 与旧目录契约。Android 0.2.2 使用分档缓存和渐进加载,旧设备回退现有方式;旧 App 可继续连接新设备。设备与 App 本轮均获用户明确迭代和部署授权。验证证据待本轮测试归档,不涉及驱动或当前帧输出。
兼容说明:原 mobile.transport 为 BLE 统计对象,保持原义;实际链路新增 active_transport,不改变旧字段类型。