Add authenticated WiFi transport with BLE fallback
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# 03 移动端接入需求
|
# 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`。当前是已确认的实施契约,不代表已部署。
|
本文件定义核桃派侧行为,手机产品需求与协议分别见 `../移动端相关内容/开发要求/移动端需求.md`、`../移动端相关内容/开发要求/设备通信协议.md`。当前是已确认的实施契约,不代表已部署。
|
||||||
|
|
||||||
| 编号 | 要求 |
|
| 编号 | 要求 |
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026-09-29T22:15+08:00
|
2026-10-01T10:37+08:00
|
||||||
|
|||||||
@@ -869,13 +869,17 @@ def create_app(
|
|||||||
mobile = MobileControl(control, network, templates, animations, resolve_content,
|
mobile = MobileControl(control, network, templates, animations, resolve_content,
|
||||||
apply_resolved_content, effective_default_content, set_default_content,
|
apply_resolved_content, effective_default_content, set_default_content,
|
||||||
read_device_status, storage_monitor.get_status, library_order=library_order)
|
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")
|
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
|
||||||
app.state.mobile_control = mobile
|
app.state.mobile_control = mobile
|
||||||
app.state.mobile_sessions = mobile_sessions
|
app.state.mobile_sessions = mobile_sessions
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Mobile control initialization failed; display remains available")
|
logger.exception("Mobile control initialization failed; display remains available")
|
||||||
app.state.mobile_runtime = mobile_runtime
|
app.state.mobile_runtime = mobile_runtime
|
||||||
|
from app.mobile.wifi import install_wifi_route
|
||||||
|
install_wifi_route(app)
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def get_status() -> dict:
|
def get_status() -> dict:
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ class MobileControl:
|
|||||||
def identity(self):
|
def identity(self):
|
||||||
record = self.identity_record
|
record = self.identity_record
|
||||||
return dict(device_id=record["device_id"], device_name=record["name"],
|
return dict(device_id=record["device_id"], device_name=record["name"],
|
||||||
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=1,
|
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"],
|
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))
|
limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000))
|
||||||
|
|
||||||
def settings(self):
|
def settings(self):
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ class Handshake:
|
|||||||
|
|
||||||
|
|
||||||
class Cipher:
|
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.tx_direction = 1 if server else 0
|
||||||
self.rx_direction = 1 - self.tx_direction
|
self.rx_direction = 1 - self.tx_direction
|
||||||
self.keys = (AESGCM(material[:32]), AESGCM(material[32:64]))
|
self.keys = (AESGCM(material[:32]), AESGCM(material[32:64]))
|
||||||
@@ -148,7 +149,7 @@ class Cipher:
|
|||||||
raise ProtocolError("BAD_REQUEST")
|
raise ProtocolError("BAD_REQUEST")
|
||||||
seq = struct.pack("!Q", self.tx_sequence)
|
seq = struct.pack("!Q", self.tx_sequence)
|
||||||
d = self.tx_direction
|
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
|
self.tx_sequence += 1
|
||||||
return result
|
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:
|
if self.failed or not 25 <= len(record) <= MAX_MESSAGE or int.from_bytes(record[:8], "big") != self.rx_sequence:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
seq, d = record[:8], self.rx_direction
|
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
|
self.rx_sequence += 1
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def checked_name(value):
|
|||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
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.dispatch = dispatch
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
@@ -37,6 +37,8 @@ class SessionManager:
|
|||||||
self.connected_at = self.last_activity = 0
|
self.connected_at = self.last_activity = 0
|
||||||
self.generation = 0
|
self.generation = 0
|
||||||
self.peer_mtu = 23
|
self.peer_mtu = 23
|
||||||
|
from .wifi import WifiSession
|
||||||
|
self.wifi = WifiSession(self, wifi_address)
|
||||||
|
|
||||||
def connect(self, owner):
|
def connect(self, owner):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -55,6 +57,7 @@ class SessionManager:
|
|||||||
self.opened = False
|
self.opened = False
|
||||||
self.last_request = -1
|
self.last_request = -1
|
||||||
self.peer_mtu = 23
|
self.peer_mtu = 23
|
||||||
|
self.wifi.close()
|
||||||
self.generation += 1
|
self.generation += 1
|
||||||
|
|
||||||
def expired(self):
|
def expired(self):
|
||||||
@@ -68,7 +71,8 @@ class SessionManager:
|
|||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return dict(connected=self.opened, client_name=self.client_name if self.opened else None,
|
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):
|
def accept(self, owner, fragment):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -119,6 +123,11 @@ class SessionManager:
|
|||||||
result = dict(client_name=self.client_name)
|
result = dict(client_name=self.client_name)
|
||||||
elif method == "session.open":
|
elif method == "session.open":
|
||||||
raise RpcError("CONFLICT", "会话已经建立")
|
raise RpcError("CONFLICT", "会话已经建立")
|
||||||
|
elif method == "transport.offer":
|
||||||
|
result = self.wifi.offer()
|
||||||
|
elif method == "transport.close":
|
||||||
|
self.wifi.close()
|
||||||
|
result = dict(closed=True)
|
||||||
else:
|
else:
|
||||||
result = self.dispatch(method, params)
|
result = self.dispatch(method, params)
|
||||||
response.update(ok=True, result=result)
|
response.update(ok=True, result=result)
|
||||||
|
|||||||
@@ -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>
|
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-statuses" aria-label="设备实时状态">
|
<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 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>
|
</button>
|
||||||
|
|||||||
@@ -769,8 +769,8 @@ export async function refreshStatus({ source = "manual" } = {}) {
|
|||||||
const mobile = data.mobile;
|
const mobile = data.mobile;
|
||||||
if (statusEls.mobile) {
|
if (statusEls.mobile) {
|
||||||
statusEls.mobile.textContent = mobile?.connected
|
statusEls.mobile.textContent = mobile?.connected
|
||||||
? `手机:${mobile.client_name || "已连接"}`
|
? `连接设备:${mobile.client_name || "手机"} · ${mobile.active_transport === "wifi" ? "WiFi" : "蓝牙"}`
|
||||||
: (mobile?.available === false ? "手机蓝牙暂不可用" : "手机未连接");
|
: "连接设备:未连接";
|
||||||
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
|
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
|
||||||
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
|
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
|
||||||
announce(`${mobile.client_name || "手机"}已连接`);
|
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
|
from PIL import Image
|
||||||
app = create_app(project_root=tmp_path, driver_kind='mock')
|
app = create_app(project_root=tmp_path, driver_kind='mock')
|
||||||
mobile = app.state.mobile_control
|
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']
|
assert 'library_progressive' in mobile.identity()['capabilities']
|
||||||
created = mobile.templates.create('预览清理', {
|
created = mobile.templates.create('预览清理', {
|
||||||
'version': 1, 'width': 64, 'height': 64,
|
'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` 的人工停顿规则执行。
|
本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。
|
||||||
|
|
||||||
## 视觉检查的适用范围与启动门禁
|
## 视觉检查的适用范围与启动门禁
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Android 可重复测试流程
|
# Android 可重复测试流程
|
||||||
|
|
||||||
|
WiFi 加速与回退专项见 [WiFi 传输测试](WiFi传输测试.md),必须显式按影响选测。
|
||||||
|
|
||||||
**先读 [按影响选测](按影响选测.md)。以下矩阵列出可选覆盖,不是每轮必跑清单;历史版本专项仅在相关影响存在时执行。**
|
**先读 [按影响选测](按影响选测.md)。以下矩阵列出可选覆盖,不是每轮必跑清单;历史版本专项仅在相关影响存在时执行。**
|
||||||
|
|
||||||
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
|
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.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
|
return output
|
||||||
api = int(adb('shell', 'getprop', 'ro.build.version.sdk', quiet=True).strip())
|
api = int(adb('shell', 'getprop', 'ro.build.version.sdk', quiet=True).strip())
|
||||||
permissions = ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT') if api >= 31 else ('ACCESS_FINE_LOCATION',)
|
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 = {}
|
permission_original = {}
|
||||||
if args.scenario == 'navigation':
|
if args.scenario == 'navigation':
|
||||||
existing = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
|
existing = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
|
||||||
permission_original = {p: 'android.permission.' + p + ': granted=true' in existing for p in permissions}
|
permission_original = {p: 'android.permission.' + p + ': granted=true' in existing for p in permissions}
|
||||||
layout_original = None
|
layout_original = None
|
||||||
layout_density = None
|
layout_density = None
|
||||||
|
restart_probe = None
|
||||||
try:
|
try:
|
||||||
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
|
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
|
||||||
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.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')
|
raise RuntimeError('Navigation must not retain permission to auto-connect')
|
||||||
if args.root_input:
|
if args.root_input:
|
||||||
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
|
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)
|
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
|
||||||
if args.scenario == "layout":
|
if args.scenario == "layout":
|
||||||
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
|
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")
|
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
|
||||||
board_args = ['-e', 'device_name', board_name] if board_name else []
|
board_args = ['-e', 'device_name', board_name] if board_name else []
|
||||||
if library_hash: board_args += ['-e', 'library_order_hash', library_hash]
|
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,
|
output = adb('shell', 'am', 'instrument', '-w', '-r', *board_args,
|
||||||
'-e', 'scenario', args.scenario,
|
'-e', 'scenario', args.scenario,
|
||||||
'-e', 'poll_cycles', str(args.poll_cycles),
|
'-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', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(),
|
||||||
'-e', 'verify_current_controls', str(args.verify_current_controls).lower(),
|
'-e', 'verify_current_controls', str(args.verify_current_controls).lower(),
|
||||||
'-e', 'toggle_bluetooth', str(args.toggle_bluetooth).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
|
'LibraryBleCacheTest' if args.scenario == 'library-cache' else
|
||||||
{'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test]),
|
{'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test]),
|
||||||
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
|
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
|
||||||
@@ -171,6 +182,14 @@ def main(argv=None):
|
|||||||
try: action()
|
try: action()
|
||||||
except Exception: errors.append(label)
|
except Exception: errors.append(label)
|
||||||
cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller', quiet=True))
|
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:
|
if layout_original and layout_density is not None:
|
||||||
cleanup('stop', lambda: root('am force-stop org.qimiaoscreen.controller'))
|
cleanup('stop', lambda: root('am force-stop org.qimiaoscreen.controller'))
|
||||||
cleanup('density', lambda: root('wm density ' + layout_density))
|
cleanup('density', lambda: root('wm density ' + layout_density))
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
# name: (instrumentation family, board required, side effect)
|
# name: (instrumentation family, board required, side effect)
|
||||||
SCENARIOS = {
|
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'),
|
'connection': ('ble', True, 'scan, handshake, disconnect and reconnect'),
|
||||||
'data': ('ble', True, 'read status/settings/storage/library/frame/wifi'),
|
'data': ('ble', True, 'read status/settings/storage/library/frame/wifi'),
|
||||||
'library-order': ('ble', True, 'read BLE pages and board order digest'),
|
'library-order': ('ble', True, 'read BLE pages and board order digest'),
|
||||||
@@ -22,6 +26,7 @@ SCENARIOS = {
|
|||||||
'remember': ('activity', True, 'forget and reconnect test device'),
|
'remember': ('activity', True, 'forget and reconnect test device'),
|
||||||
'bluetooth-toggle': ('activity', True, 'disable and restore phone Bluetooth'),
|
'bluetooth-toggle': ('activity', True, 'disable and restore phone Bluetooth'),
|
||||||
'connection-entry': ('layout', True, 'connected entry, rescan and reconnect; layout restore'),
|
'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'),
|
'layout': ('layout', True, 'three layout configurations and frame bounds; layout restore'),
|
||||||
}
|
}
|
||||||
COMMON = ('connection', 'data', 'library-order', 'navigation')
|
COMMON = ('connection', 'data', 'library-order', 'navigation')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
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'),
|
CONFIGS = {'baseline': (None, '1.0', '0'), 'narrow': (340, '1.0', '0'),
|
||||||
'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')}
|
'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')}
|
||||||
PACKAGE = 'org.qimiaoscreen.uitest'
|
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 测试常见问题与排障
|
# 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) 整理,方法有效不等于所有同类错误均有相同根因。
|
测试前阅读本文件及 [可重复测试流程](README.md)。这里维护可复用的方法;单次失败、重测次数、耗时与验收结果写入 `../测试结果归档/`。本页依据 [四栏版真机记录](../测试结果归档/2026-09-26_四栏界面优化.md) 整理,方法有效不等于所有同类错误均有相同根因。
|
||||||
|
|
||||||
## 1. root 命令在 instrumentation 中失败
|
## 1. root 命令在 instrumentation 中失败
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Android 按变更影响选测(当前规则)
|
# Android 按变更影响选测(当前规则)
|
||||||
|
|
||||||
|
WiFi 通道新增场景、前置条件和验收标准见 [WiFi 传输测试](WiFi传输测试.md)。`wifi-transport-recovery` 切手机无线开关,`wifi-transport-network` 重新应用设备当前网络,`wifi-transport-restart` 重启主服务,均不得并入普通全量或公共回归。`wifiTransport` 是独立模拟器场景,不接实机。
|
||||||
|
|
||||||
适用 MOBILE-TEST-SELECTION、TEST-MOBILE-SELECTION、TEST-MOBILE-INTEGRATION。本文优先于历史版本专项验收中的固定执行顺序。授权决定怎样操作设备,不代表每轮必须使用设备。
|
适用 MOBILE-TEST-SELECTION、TEST-MOBILE-SELECTION、TEST-MOBILE-INTEGRATION。本文优先于历史版本专项验收中的固定执行顺序。授权决定怎样操作设备,不代表每轮必须使用设备。
|
||||||
|
|
||||||
## 选择范围
|
## 选择范围
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Android 首版详细设计
|
# 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。已实现项以本文件说明及源码为准,真实通过范围见测试结果归档;测试目标不自动等于已完成验收。
|
本设计对应 MOBILE-* 和协议 1.0。已实现项以本文件说明及源码为准,真实通过范围见测试结果归档;测试目标不自动等于已完成验收。
|
||||||
|
|
||||||
## 1. 模块与依赖方向
|
## 1. 模块与依赖方向
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "org.qimiaoscreen.controller"
|
applicationId = "org.qimiaoscreen.controller"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "0.2.2"
|
versionName = "0.3.0"
|
||||||
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
|
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation(project(":sharedCore"))
|
implementation(project(":sharedCore"))
|
||||||
implementation(project(":sharedUi"))
|
implementation(project(":sharedUi"))
|
||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
|
|||||||
+4
@@ -108,6 +108,10 @@ class LayoutTest {
|
|||||||
try {
|
try {
|
||||||
stage("initial-connect")
|
stage("initial-connect")
|
||||||
if (visible(By.res("connected_scan_disconnect"), 35_000, scroll = true) == null) connectScanned()
|
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")
|
val summary = tagged("connection_summary")
|
||||||
assertTrue(summary.text.startsWith("当前连接设备:"))
|
assertTrue(summary.text.startsWith("当前连接设备:"))
|
||||||
assertTrue(summary.text.contains(" · "))
|
assertTrue(summary.text.contains(" · "))
|
||||||
|
|||||||
+169
@@ -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">
|
<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-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" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" 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.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<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">
|
<activity android:name=".MainActivity" android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
+2
@@ -64,6 +64,7 @@ class AndroidBle(private val context: Context) {
|
|||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private class GattLink : DeviceLink {
|
private class GattLink : DeviceLink {
|
||||||
|
override val disconnected = CompletableDeferred<Unit>()
|
||||||
@Volatile override var mtu = 23
|
@Volatile override var mtu = 23
|
||||||
private set
|
private set
|
||||||
val ready = CompletableDeferred<Unit>()
|
val ready = CompletableDeferred<Unit>()
|
||||||
@@ -167,6 +168,7 @@ private class GattLink : DeviceLink {
|
|||||||
} catch (_: TimeoutCancellationException) { null } finally { signalPending = null }
|
} catch (_: TimeoutCancellationException) { null } finally { signalPending = null }
|
||||||
}
|
}
|
||||||
override fun close() {
|
override fun close() {
|
||||||
|
disconnected.complete(Unit)
|
||||||
if (closed) return
|
if (closed) return
|
||||||
closed = true
|
closed = true
|
||||||
handler.removeCallbacks(discoveryFallback)
|
handler.removeCallbacks(discoveryFallback)
|
||||||
|
|||||||
+123
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -23,6 +23,15 @@ import org.qimiaoscreen.ui.AppInfo
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private lateinit var controller: Controller
|
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 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 fun permitted() = permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||||
private val requestPermission: ActivityResultLauncher<Array<String>> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
private val requestPermission: ActivityResultLauncher<Array<String>> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||||
@@ -42,8 +51,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val ble = AndroidBle(applicationContext)
|
val ble = AndroidBle(applicationContext)
|
||||||
|
wifi = AndroidWifi(applicationContext)
|
||||||
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
|
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
|
||||||
val platform = object : MobilePlatform {
|
val platform = object : MobilePlatform {
|
||||||
|
override fun wifi(): WifiPlatform = wifi
|
||||||
|
override fun requestWifiPermission() { requestLan() }
|
||||||
override val model = Build.MODEL
|
override val model = Build.MODEL
|
||||||
override val libraryCache: LibraryCache = AndroidLibraryCache(applicationContext)
|
override val libraryCache: LibraryCache = AndroidLibraryCache(applicationContext)
|
||||||
override fun load(key: String) = preferences.getString(key, null)
|
override fun load(key: String) = preferences.getString(key, null)
|
||||||
@@ -54,13 +66,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun crypto(): SecureChannel = JavaSecureChannel()
|
override fun crypto(): SecureChannel = JavaSecureChannel()
|
||||||
}
|
}
|
||||||
controller = Controller(platform, scope)
|
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 = {
|
setContent { QimiaoApp(controller, onScan = {
|
||||||
if (permitted()) controller.scan() else requestPermission.launch(permissions)
|
if (permitted()) controller.scan() else requestPermission.launch(permissions)
|
||||||
}, appInfo = AppInfo(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toString(), BuildConfig.BUILD_DATE), decode = { encoded ->
|
}, 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()
|
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 onStop() { if (::controller.isInitialized) controller.foreground(false); super.onStop() }
|
||||||
override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() }
|
override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() }
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -8,6 +8,8 @@ import kotlinx.serialization.json.*
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
interface MobilePlatform {
|
interface MobilePlatform {
|
||||||
|
fun wifi(): WifiPlatform? = null
|
||||||
|
fun requestWifiPermission() {}
|
||||||
val model: String
|
val model: String
|
||||||
val libraryCache: LibraryCache? get() = null
|
val libraryCache: LibraryCache? get() = null
|
||||||
fun load(key: String): String?
|
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,
|
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 = "")
|
val address: String = "", val prefix: String = "24", val gateway: String = "", val dns: String = "")
|
||||||
data class ControllerState(
|
data class ControllerState(
|
||||||
|
val transport: String = "ble", val transportReason: String? = null,
|
||||||
val remembered: List<RememberedDevice> = emptyList(), val signal: Int? = null,
|
val remembered: List<RememberedDevice> = emptyList(), val signal: Int? = null,
|
||||||
val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null,
|
val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null,
|
||||||
val wifiDraft: WifiDraft? = 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 requestedDeviceId: String? = null
|
||||||
private var activeLink: DeviceLink? = null
|
private var activeLink: DeviceLink? = null
|
||||||
fun wifiDraft(value: WifiDraft) = change { it.copy(wifiDraft = value) }
|
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 val commands = Mutex()
|
||||||
private var scanJob: Job? = null
|
private var scanJob: Job? = null
|
||||||
private var pollJob: 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
|
val id = identity.getValue("device_id").jsonPrimitive.content
|
||||||
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
|
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
|
||||||
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
|
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("last_device_id", id)
|
||||||
platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString())
|
platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString())
|
||||||
persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id,
|
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) {
|
} catch (e: Exception) {
|
||||||
pending?.close()
|
pending?.close()
|
||||||
if (generation == connectionGeneration) {
|
if (generation == connectionGeneration) {
|
||||||
|
rpc?.close()
|
||||||
rpc = null; activeLink = null
|
rpc = null; activeLink = null
|
||||||
if (e !is CancellationException) change { it.copy(identity = null, connectedDevice = null, phase = "未连接", error = e.message ?: "连接失败") }
|
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) }
|
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
|
||||||
}
|
}
|
||||||
private fun connectionLost(message: String) {
|
private fun connectionLost(message: String) {
|
||||||
|
connectionGeneration++
|
||||||
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; resetLibraryQueue()
|
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) }
|
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
|
||||||
}
|
}
|
||||||
fun foreground(value: Boolean) {
|
fun foreground(value: Boolean) {
|
||||||
foreground = value
|
foreground = value
|
||||||
|
rpc?.foreground(value)
|
||||||
releaseJob?.cancel()
|
releaseJob?.cancel()
|
||||||
if (value) {
|
if (value) {
|
||||||
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
|
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
|
val generation = connectionGeneration
|
||||||
commandJob = scope.launch {
|
commandJob = scope.launch {
|
||||||
try { commands.withLock { block() } }
|
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) {
|
catch (e: Exception) {
|
||||||
if (e !is CancellationException && generation == connectionGeneration) {
|
if (e !is CancellationException && generation == connectionGeneration) {
|
||||||
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
|
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
|
||||||
|
|||||||
+1
@@ -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. */
|
/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */
|
||||||
interface DeviceLink {
|
interface DeviceLink {
|
||||||
|
val disconnected: kotlinx.coroutines.Deferred<Unit>? get() = null
|
||||||
val mtu: Int
|
val mtu: Int
|
||||||
suspend fun send(fragment: ByteArray)
|
suspend fun send(fragment: ByteArray)
|
||||||
suspend fun receive(): ByteArray
|
suspend fun receive(): ByteArray
|
||||||
|
|||||||
+140
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -27,12 +27,30 @@ class JavaSecureChannel(
|
|||||||
}.generateKeyPair(),
|
}.generateKeyPair(),
|
||||||
private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) },
|
private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) },
|
||||||
) : SecureChannel {
|
) : SecureChannel {
|
||||||
private val label = "QMS-BLE-1".encodeToByteArray()
|
private var label = "QMS-BLE-1".encodeToByteArray()
|
||||||
private var material: ByteArray? = null
|
private var material: ByteArray? = null
|
||||||
private var transcript = ByteArray(0)
|
private var transcript = ByteArray(0)
|
||||||
private var tx = 0L
|
private var tx = 0L
|
||||||
private var rx = 0L
|
private var rx = 0L
|
||||||
private var failed = false
|
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 {
|
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
|
if (it.size >= 32) it.copyOfRange(it.size - 32, it.size) else ByteArray(32 - it.size) + it
|
||||||
}
|
}
|
||||||
|
|||||||
+136
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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"
|
||||||
|
}
|
||||||
+12
-3
@@ -93,20 +93,29 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Unit) {
|
private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Unit) {
|
||||||
var selected by remember { mutableStateOf<RememberedDevice?>(null) }
|
var selected by remember { mutableStateOf<RememberedDevice?>(null) }
|
||||||
var renaming by remember { mutableStateOf(false) }
|
var renaming by remember { mutableStateOf(false) }
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
LazyColumn(Modifier.fillMaxWidth().testTag("connection_list"), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
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) }
|
item { Text("已保存的设备", style = MaterialTheme.typography.titleMedium) }
|
||||||
if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") }
|
if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") }
|
||||||
items(state.remembered, key = { it.id }) { device ->
|
items(state.remembered, key = { it.id }) { device ->
|
||||||
Card(Modifier.fillMaxWidth().combinedClickable(onClick = { c.connectRemembered(device) }, onLongClick = { selected = device; renaming = false })) {
|
Card(Modifier.fillMaxWidth().combinedClickable(onClick = { c.connectRemembered(device) }, onLongClick = { selected = device; renaming = false })) {
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(Modifier.weight(1f)) { Text(device.name); Text("代号 ${device.shortId}", style = MaterialTheme.typography.bodySmall) }
|
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) {
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(if (connected) state.identity!!.text("device_name") else device.name)
|
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)
|
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) },
|
Button({ if (connected) c.disconnect() else c.connect(device, c.rememberedFor(device)?.id) },
|
||||||
|
|||||||
+36
-1
@@ -22,6 +22,9 @@ class FixtureActivity : ComponentActivity() {
|
|||||||
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
|
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
|
||||||
}
|
}
|
||||||
class FixturePlatform : MobilePlatform {
|
class FixturePlatform : MobilePlatform {
|
||||||
|
var wifiEnabled = false
|
||||||
|
val fixtureWifi = FixtureWifi()
|
||||||
|
override fun wifi(): WifiPlatform? = if (wifiEnabled) fixtureWifi else null
|
||||||
override val model = "模拟测试手机"
|
override val model = "模拟测试手机"
|
||||||
private val saved = mutableMapOf<String, String>()
|
private val saved = mutableMapOf<String, String>()
|
||||||
val calls = mutableListOf<String>()
|
val calls = mutableListOf<String>()
|
||||||
@@ -56,7 +59,9 @@ private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
|
|||||||
val method = request.getValue("method").jsonPrimitive.content
|
val method = request.getValue("method").jsonPrimitive.content
|
||||||
platform.calls += method
|
platform.calls += method
|
||||||
val result = when (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}"""
|
"session.ping" -> """{"alive":true}"""
|
||||||
"status.get" -> """{"state":{"animation_playback":{"active":false}}}"""
|
"status.get" -> """{"state":{"animation_playback":{"active":false}}}"""
|
||||||
"frame.get" -> """{"frame_revision":"fixture","data_base64":"fixture-image"}"""
|
"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 suspend fun receive() = messages.receive()
|
||||||
override fun close() { messages.close() }
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
@@ -7,6 +7,19 @@ import org.junit.Test
|
|||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
|
|
||||||
class IsolatedUiTest {
|
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>()
|
@get:Rule val ui = createAndroidComposeRule<FixtureActivity>()
|
||||||
private fun click(text: String) { ui.onNodeWithText(text).performClick() }
|
private fun click(text: String) { ui.onNodeWithText(text).performClick() }
|
||||||
private fun connect() {
|
private fun connect() {
|
||||||
@@ -24,6 +37,7 @@ class IsolatedUiTest {
|
|||||||
ui.onNodeWithTag("controller_error").assertDoesNotExist()
|
ui.onNodeWithTag("controller_error").assertDoesNotExist()
|
||||||
ui.runOnIdle { ui.activity.platform.scanError = false }
|
ui.runOnIdle { ui.activity.platform.scanError = false }
|
||||||
connect()
|
connect()
|
||||||
|
ui.onNodeWithText("连接状态:蓝牙", substring = false).assertExists()
|
||||||
ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick()
|
ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick()
|
||||||
ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null }
|
ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Android 环境、构建与交付
|
# 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。
|
Android Studio 是正常安装软件,由用户运行已核验官方安装包。安装包放用户普通下载目录,不放仓库或 Codex bin。准备好后停止等待用户安装,不自动运行安装器。安装时建议采用默认位置,首次向导安装 Android SDK;不必创建项目,也不必先下载模拟器或 NDK。
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def main():
|
|||||||
build_date = re.search(r'BUILD_DATE = "([^"]+)"', build_config)[1]
|
build_date = re.search(r'BUILD_DATE = "([^"]+)"', build_config)[1]
|
||||||
manifest = dict(app_version=version, version_code=code, signing='debug', build_date=build_date,
|
manifest = dict(app_version=version, version_code=code, signing='debug', build_date=build_date,
|
||||||
status='development-validation-see-test-report', apk_sha256=digest(apk),
|
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)
|
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 / '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')
|
(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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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。
|
更新时间: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. 产品与范围
|
## 1. 产品与范围
|
||||||
|
|
||||||
名称:奇妙小屏幕。Android 首版为 BLE 控制器,支持 Android 8.0(API 26)以上。连接不要求核桃派已联网,也不要求手机与设备在同一 WiFi。设备、内容、设置为三个一级页面,中文为首版界面语言。
|
名称:奇妙小屏幕。Android 首版为 BLE 控制器,支持 Android 8.0(API 26)以上。连接不要求核桃派已联网,也不要求手机与设备在同一 WiFi。设备、内容、设置为三个一级页面,中文为首版界面语言。
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 奇妙小屏幕 BLE 控制协议 1.0
|
# 奇妙小屏幕 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、任意路径或原生驱动地址。变化登记到跨端兼容记录。
|
本文是首版实现契约。Python/Kotlin 加密黄金向量、分片和 Android 13 真实 BLE 已有通过记录;各业务验收范围见测试结果归档,不能据此推断所有场景通过。命令只能进入设备公共业务服务,不调用网页 DOM,不提供 shell、任意路径或原生驱动地址。变化登记到跨端兼容记录。
|
||||||
|
|
||||||
## 1. GATT 与广播
|
## 1. GATT 与广播
|
||||||
@@ -91,3 +101,5 @@ BLE 只接收白名单方法。旧客户端遇新能力忽略;旧设备缺某
|
|||||||
## 2026-09-26 兼容扩展
|
## 2026-09-26 兼容扩展
|
||||||
library.list 的用户条目遵循设备持久混合顺序,顺序变化影响 library_revision 和游标。演示条目固定在用户条目之前。
|
library.list 的用户条目遵循设备持久混合顺序,顺序变化影响 library_revision 和游标。演示条目固定在用户条目之前。
|
||||||
task.get 可附加脱敏 error_code(AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN);旧客户端可忽略。没有可靠原因时用 UNKNOWN。wifi.get 仍不含密码。协议主版本保持 1。
|
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 协议主次版本独立编号。
|
- 设备版本取设备自己的 VERSION,App 版本取 Android 工程独立版本,BLE 协议主次版本独立编号。
|
||||||
@@ -66,3 +70,5 @@ MOBILE-STATUS/DISCOVERY/PREVIEW:顶栏自动循环、扫描条目断开与
|
|||||||
## 2026-09-29 内容图库缓存
|
## 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 本轮均获用户明确迭代和部署授权。验证证据待本轮测试归档,不涉及驱动或当前帧输出。
|
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,不改变旧字段类型。
|
||||||
|
|||||||
Reference in New Issue
Block a user